Compare commits
84 Commits
0a7c4e129c
...
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 | |||
| 8c353c6c66 | |||
| 3a038fe406 | |||
| 3bb6b4fc9d | |||
| a9bac8be8e | |||
| 3c28b0d1af | |||
| 4936654f84 | |||
| 4156dc5810 | |||
| fc1fdcc5ab | |||
| 1e8d46b258 | |||
| 8ae432223a | |||
| 9026220533 | |||
| f0c6dcf238 | |||
| 6c97bc4e2b | |||
| 3c67fea074 | |||
| 2a9507cb6a | |||
| 5b8bee286c | |||
| ba19954ffb | |||
| 88b4cad780 | |||
| a4c6aeed49 | |||
| 97498c560e | |||
| 8cb2a0f9c6 | |||
| 9f445904a5 | |||
| ce5d4204ac | |||
| 6ca735749e | |||
| 739228efdb | |||
| db5fb37980 |
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
|
**`+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
|
right. The pre-match kit selector gates on `+0x60 == 4` at `0x1801c34f2`, and no
|
||||||
gates on `+0x60 == 4` at `0x1801c34f2`, and **nothing anywhere stores 4 into that
|
instruction in CardsDLL stores that constant immediately (29 stores, constants
|
||||||
field** — not in CardsDLL (29 immediate stores, constants `{-2,0,1,908,0x3f800000}`),
|
`{-2,0,1,908,0x3f800000}`), nor does FIFA17.exe across 79 MB.
|
||||||
not in FIFA17.exe (zero across 79 MB), and no resident record has ever held it
|
Tool: `fifa17-recon/tools/kit_gate_probe.py`.
|
||||||
(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
|
**CORRECTED 2026-08-23 (live, pid 8793, read-only `/proc/PID/mem`).** The
|
||||||
wire field. Tool: `fifa17-recon/tools/kit_gate_probe.py`.
|
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
|
**`definitionId is NOT AN ATOM`, confirmed a fourth way 2026-08-21.** Every real
|
||||||
atom name appears exactly once in CardsDLL's `.rdata` — `resourceId`,
|
atom name appears exactly once in CardsDLL's `.rdata` — `resourceId`,
|
||||||
|
|||||||
@@ -105,3 +105,302 @@ So applying a consumable is **not** a dedicated server route. If it reaches the
|
|||||||
server at all it must ride `PUT ut/%s/item`, and L5/L6 should be pursued by
|
server at all it must ride `PUT ut/%s/item`, and L5/L6 should be pursued by
|
||||||
capturing that PUT's payload while applying a card — not by looking for an
|
capturing that PUT's payload while applying a card — not by looking for an
|
||||||
endpoint that does not exist.
|
endpoint that does not exist.
|
||||||
|
|
||||||
|
## FUT task vocabulary (2026-08-21, live)
|
||||||
|
|
||||||
|
The client drives UTAS through named TASKS, not just URLs. The task-name table
|
||||||
|
lives in CardsDLL `.rdata` as 0x20-byte inline slots holding MixedCase/UPPERCASE
|
||||||
|
pairs (`tools/apply_route_search.py`, controls `tradePile`/`ut/%s/item`/`squad`
|
||||||
|
all FOUND):
|
||||||
|
|
||||||
|
```
|
||||||
|
ViewCards AssingCard(sic) ApplyCard ApplyCardByRes
|
||||||
|
ActivateCard ConsumeCard DiscardCard DiscardCardByRes
|
||||||
|
DiscardACard MoveCard MoveCardByRes SwapCard
|
||||||
|
CreateMatch MatchReady DestroyMatch PlayGame ResetMatch KeepAlive
|
||||||
|
LoadCategoryDetails LoadSetChallenges StartChallenge LoadSquadChallenge
|
||||||
|
SaveSquadChallenge SubmitChallenge TagSets SetSbcData
|
||||||
|
TournamentList TournamentTeams SetUserInfo GetHistorical SetTutData ...
|
||||||
|
```
|
||||||
|
|
||||||
|
A descriptor table in `.data` pairs each name with a task id and a small setter
|
||||||
|
thunk, e.g. `ApplyCard` id **0x0d** at `0x1802cb170`, `ApplyCardByRes` id **0x0e**
|
||||||
|
at `0x1802cb1a0`. The thunks are `mov [rip+flag], cl; ret` (a per-task flag), NOT
|
||||||
|
request builders, so the request is assembled elsewhere keyed by task id.
|
||||||
|
|
||||||
|
**So consumable application IS a first-class client action (`ApplyCard` /
|
||||||
|
`ApplyCardByRes` / `ConsumeCard`), even though no `/apply` URL exists.** It
|
||||||
|
therefore rides an existing route. Which one is a one-capture question, and the
|
||||||
|
host now names every unclaimed request:
|
||||||
|
|
||||||
|
```
|
||||||
|
utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N
|
||||||
|
```
|
||||||
|
|
||||||
|
## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21)
|
||||||
|
|
||||||
|
Captured end to end on staging, operator applying a bronze player contract:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /ut/game/fifa17/item/resource/5001004
|
||||||
|
{"apply":[{"id":100000003}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
| element | value | where |
|
||||||
|
|---|---|---|
|
||||||
|
| source consumable | resource id `5001004` (player contract, subtype 201) | **path** |
|
||||||
|
| target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` |
|
||||||
|
| verb | `POST` | |
|
||||||
|
|
||||||
|
**There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which
|
||||||
|
we already serve for **GET** (item-definition lookup). The **POST** verb on that
|
||||||
|
path is the mutation, and nothing claimed it, so it fell through to Python. This
|
||||||
|
is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by
|
||||||
|
res**ource" -- which is why the source is a definition id rather than an instance
|
||||||
|
id.
|
||||||
|
|
||||||
|
`apply` is an ARRAY, so one consumable resource can name several targets in a
|
||||||
|
single request. Whether the client ever batches is unobserved.
|
||||||
|
|
||||||
|
Corroborating UI evidence from the same session: applying to a PLAYER offered
|
||||||
|
only the subtype-201 card and withheld both subtype-202 manager contracts,
|
||||||
|
independently confirming the `201 = player_contract / 202 = manager_contract`
|
||||||
|
split.
|
||||||
|
|
||||||
|
Fail-closed confirmed: with the upstream dead the request 502s and Core is left
|
||||||
|
EXACTLY unchanged (coins, owned count, and the source card all identical).
|
||||||
|
|
||||||
|
### Not yet known
|
||||||
|
* the **response shape** the client expects on success;
|
||||||
|
* the **effect** -- how many matches a contract grants. Our own catalog carries
|
||||||
|
`contract: 7` for `5001004`, documented as "the number of matches the card
|
||||||
|
grants", but that is observed profile data, i.e. INFERRED, not reversed. No
|
||||||
|
effect is implemented on that basis.
|
||||||
|
|
||||||
|
## Consumables category `development` is unmapped (client really asks)
|
||||||
|
|
||||||
|
The new passthrough/route logging caught the client requesting
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0
|
||||||
|
```
|
||||||
|
|
||||||
|
`consumable_families_for_category` has no `development` arm, so the screen is
|
||||||
|
served empty. The client demonstrably asks for it, which is exactly the condition
|
||||||
|
that function's own doc says should add an arm. Which families it should map to
|
||||||
|
is NOT guessed here.
|
||||||
|
|
||||||
|
### Success contract — STATIC_REVERSED (2026-08-22)
|
||||||
|
|
||||||
|
The apply completion handler is `0x180035520`:
|
||||||
|
|
||||||
|
```asm
|
||||||
|
0x180035529 mov ecx,DWORD PTR [rdx+0x1c] ; the ONLY field tested
|
||||||
|
0x18003552c test ecx,ecx
|
||||||
|
0x18003552e jne 0x18003555c ; nonzero -> FAILURE
|
||||||
|
0x18003553c lea rdx,[EVENT_CARDS_APPLY_CARD_SUCCESS] ; 0x1801f37f0
|
||||||
|
0x180035569 lea rdx,[EVENT_CARDS_APPLY_CARD_FAILURE] ; 0x1801f3810
|
||||||
|
```
|
||||||
|
|
||||||
|
It tests exactly one 32-bit field — the transport code — and **never inspects
|
||||||
|
the body**. `EVENT_CARDS_APPLY_CARD_SUCCESS` has precisely one reference in the
|
||||||
|
module, so this is the whole verdict path.
|
||||||
|
|
||||||
|
This does NOT resemble the move ack (`0x180128600`), which builds per-item
|
||||||
|
verdict records and reports FAILURE on an EMPTY vector. The "`{}` is
|
||||||
|
known-broken" precedent is specific to that route and does not transfer here.
|
||||||
|
|
||||||
|
Supporting structure: the response object's constructor `0x1800a4ce0` installs
|
||||||
|
vtable `0x1801fb5b0` and initialises its record vector at `+0x50`/`+0x58`/`+0x60`
|
||||||
|
EMPTY (0x20-byte elements); `0x1800682b0` is the matching destructor, freeing
|
||||||
|
that range with a 0x20 stride. An empty result is therefore a legal parsed state
|
||||||
|
for this response, unlike the move.
|
||||||
|
|
||||||
|
Registration site: `0x1800357da` installs the completion handler and
|
||||||
|
`0x1800357e5` the response factory, back to back.
|
||||||
|
|
||||||
|
**Probe response**: `{"itemData":[]}` — an object root (matching how the oracle's
|
||||||
|
method-agnostic `item/resource` route answers this path) containing an empty
|
||||||
|
vector (legal per the constructor). Labelled a PROBE. The client's SUCCESS only
|
||||||
|
requires transport code 0.
|
||||||
|
|
||||||
|
## Consumables categories — nine, not seven (2026-08-22)
|
||||||
|
|
||||||
|
Correcting the earlier claim that the two formation-modifier families "have no
|
||||||
|
group code, so no segment can reach them — the client's own gap". The client's
|
||||||
|
own switch says otherwise. Literal table at `0x1801f5a38` (under
|
||||||
|
`MyClubAdapterClass` / `CONSUMABLE_TYPE`); switch at `0x180048820` indexing by
|
||||||
|
`enum + 1` through the byte table at `0x180048a90` into the case table at
|
||||||
|
`0x180048a6c`:
|
||||||
|
|
||||||
|
| CONSUMABLE_TYPE | segment |
|
||||||
|
|---|---|
|
||||||
|
| **-1 (unset)** | `development` |
|
||||||
|
| 1, 2 | `contracts` |
|
||||||
|
| 3 | `healing` |
|
||||||
|
| 4 | `fitness` |
|
||||||
|
| **16** | `formation` |
|
||||||
|
| 17 | `position` |
|
||||||
|
| 23 | `playStyle` |
|
||||||
|
| 24 | `managerLeagueModifier` |
|
||||||
|
| 0, 5..15, 18..22 | `training` (switch default) |
|
||||||
|
|
||||||
|
`formation` was a SERVER gap, not a client one. `development` is the type-unset
|
||||||
|
bucket — index 0 of an `enum + 1` table — i.e. the unfiltered view; the eight
|
||||||
|
typed segments already reach all thirteen families exactly once, so it owns no
|
||||||
|
family privately and maps to their union.
|
||||||
|
|
||||||
|
## Contract effect — the `contract: 7` inference is REFUTED at the source
|
||||||
|
|
||||||
|
Do not implement a contract effect from the catalog's `contract: 7`.
|
||||||
|
|
||||||
|
`fifa17-recon/tools/fut_store.py:232` — the generic `_item()` factory that builds
|
||||||
|
EVERY item the oracle serves — hardcodes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"playStyle": 250,
|
||||||
|
"contract": 7,
|
||||||
|
"fitness": 99,
|
||||||
|
```
|
||||||
|
|
||||||
|
These are blanket placeholders on every item, players and consumables alike. The
|
||||||
|
staging squad's GK reads back `contract 7 / fitness 99 / playStyle 250`: the same
|
||||||
|
three constants. So the `contract: 7` carried in the production catalog for
|
||||||
|
resource 5001004 is **our own oracle placeholder round-tripped through an
|
||||||
|
observed profile**, not an EA value. Its evidence level is not INFERRED; it is
|
||||||
|
KNOWN-BOGUS as a source of the effect.
|
||||||
|
|
||||||
|
### What the client's own table does say
|
||||||
|
|
||||||
|
`fcc_contractcards` (13 rows) is NOT amount-less, contrary to an earlier note
|
||||||
|
here. Columns: `carddbid, cardsubtype, weightrare, cardassetid, gold, rating,
|
||||||
|
bronze, silver`.
|
||||||
|
|
||||||
|
| rating | player (201) | manager (202) | gold | silver | bronze |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 50 | 5001001 | 5001007 | 1 | 2 | 8 |
|
||||||
|
| 65 | 5001002 | 5001008 | 8 | 10 | 10 / 8 |
|
||||||
|
| 80 | 5001003 | 5001009 | 13 | 11 | 15 / 11 |
|
||||||
|
| 60 | 5001004 | 5001010 | 3 | 6 | 15 |
|
||||||
|
| 70 | 5001005 | 5001011 | 18 | 24 | 20 / 18 |
|
||||||
|
| 90 | 5001006 | 5001012 | 28 | 24 | 28 / 24 |
|
||||||
|
| 90 | 5001013 | — | 99 | 99 | 99 |
|
||||||
|
|
||||||
|
Compare the sibling `fcc_healingcards`, which shares `carddbid, cardsubtype,
|
||||||
|
weightrare, cardassetid, rating` and differs only by carrying a single `amount`.
|
||||||
|
So `weightrare` is the drop weight and the differing column(s) are the effect
|
||||||
|
payload — which would make gold/silver/bronze a per-target-tier amount.
|
||||||
|
|
||||||
|
AGAINST that reading: the values are not monotonic across tiers (5001005 is gold
|
||||||
|
18, silver 24, bronze 20; 5001003 is gold 13, silver 11, bronze 15), which is
|
||||||
|
odd for an amount and unremarkable for a weight. Note also that **no column of
|
||||||
|
5001004 equals 7**, so nothing here explains the placeholder either way.
|
||||||
|
|
||||||
|
Unresolved, and NOT to be guessed: the fcc tables are loaded by `FIFA17.exe`, not
|
||||||
|
CardsDLL (the table-name and column literals are absent from the DLL), so the
|
||||||
|
reader that would settle amount-vs-weight lives in the EXE. Status stays
|
||||||
|
**EFFECT_UNKNOWN**.
|
||||||
|
|
||||||
|
## Post-ACK behaviour — OUTCOME B, LIVE_PROVEN (2026-08-22)
|
||||||
|
|
||||||
|
Captured with the staging probe answering `200 {"itemData":[]}` and mutating
|
||||||
|
nothing:
|
||||||
|
|
||||||
|
```
|
||||||
|
T0 POST /ut/game/fifa17/item/resource/5001004 {"apply":[{"id":100000003}]}
|
||||||
|
T1 200 {"itemData":[]}
|
||||||
|
T2 callback -> SUCCESS (no failure event; ZERO ut/delete/auth; session alive)
|
||||||
|
T4 GET club/consumables/contracts <- refresh of the SOURCE list
|
||||||
|
T5 GET club/consumables/development
|
||||||
|
T6 GET squad/active <- refresh of the TARGET
|
||||||
|
T7 no second mutation of any kind
|
||||||
|
```
|
||||||
|
|
||||||
|
So of the candidate protocols:
|
||||||
|
|
||||||
|
```
|
||||||
|
B) POST resource -> ACK -> client performs GET refresh
|
||||||
|
-> the SERVER is expected to have mutated state
|
||||||
|
```
|
||||||
|
|
||||||
|
Ruled out by observation: (A) the response carries the modified state — the body
|
||||||
|
was empty and the client was satisfied; (C) a follow-up generic PUT/item — none
|
||||||
|
was sent; (D) another route performs the mutation — nothing else was called.
|
||||||
|
|
||||||
|
Three consequences.
|
||||||
|
|
||||||
|
1. **The success verdict is transport-only, confirmed live.** The static read of
|
||||||
|
`0x180035520` said the body is never inspected; an empty `itemData` produced a
|
||||||
|
clean success and a surviving session, which is that prediction holding.
|
||||||
|
2. **The server owns the effect entirely.** The client does not compute one; it
|
||||||
|
re-reads. This is the good failure mode: a wrong server-side effect cannot be
|
||||||
|
masked by client-side optimism, and the refresh will always show server truth.
|
||||||
|
Here the refresh correctly showed `contracts copies=3` and an unchanged squad,
|
||||||
|
because the probe consumed nothing.
|
||||||
|
3. **There is no client-side amount to harvest.** Since the client never renders
|
||||||
|
an optimistic "+N games" of its own, the live path cannot reveal the grant
|
||||||
|
size. The number the client DISPLAYS on a contract card comes from the wire
|
||||||
|
`contract` atom (0xb8 -> record+0x8c; see `fut_consumables.py`, which notes
|
||||||
|
categories 2 and 3 ignore `amount` and read `contract`) — i.e. the server
|
||||||
|
tells the client what the card is worth.
|
||||||
|
|
||||||
|
That last point matters for honesty: our oracle has been sending the placeholder
|
||||||
|
`7` for that atom, so every contract card this project has ever shown a player
|
||||||
|
said "7" because WE said 7. Recovering EA's real value is not reachable from the
|
||||||
|
client's behaviour; it needs the `FIFA17.exe` reader of `fcc_contractcards`, or
|
||||||
|
it becomes an explicit design decision. Status: **EFFECT_UNKNOWN**.
|
||||||
|
|
||||||
|
### Boundary status
|
||||||
|
|
||||||
|
| aspect | status |
|
||||||
|
|---|---|
|
||||||
|
| route, method, source encoding, target encoding | LIVE_PROVEN |
|
||||||
|
| success condition (`[obj+0x1c] == 0`, body ignored) | STATIC_REVERSED + LIVE_CONFIRMED |
|
||||||
|
| response shape accepted by the client | LIVE_PROVEN (`{"itemData":[]}`, session survived) |
|
||||||
|
| post-ACK protocol | LIVE_PROVEN — outcome B |
|
||||||
|
| batching | UNPROVEN — refused, never guessed |
|
||||||
|
| contract effect / grant size | UNKNOWN (placeholder source refuted) |
|
||||||
|
| source instance selection with multiple copies | UNDETERMINED (only 1 copy owned) |
|
||||||
|
|
||||||
|
## Consumable QUICK-SELL is PUT item/resource — LIVE_PROVEN (2026-08-22)
|
||||||
|
|
||||||
|
Captured on staging when the operator quick-sold a Position Modifier from the
|
||||||
|
consumables screen:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /ut/game/fifa17/item/resource/5003068 body_len=0
|
||||||
|
```
|
||||||
|
|
||||||
|
So `ut/<sku>/item/resource/<resourceId>` carries THREE verbs, and this is the
|
||||||
|
third:
|
||||||
|
|
||||||
|
| verb | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `GET` | item-definition lookup (`defs_route` parity) |
|
||||||
|
| `POST` | apply the consumable (`ApplyCardByRes`, body `{"apply":[{"id":N}]}`) |
|
||||||
|
| `PUT` | **quick-sell the consumable**, EMPTY body |
|
||||||
|
|
||||||
|
Note it is keyed by **resourceId**, i.e. the STACK, not by an owned instance
|
||||||
|
id — unlike the player quick-sell, which is `DELETE ut/<sku>/item/<instanceId>`
|
||||||
|
and is retail-proven in production. That asymmetry follows the consumables
|
||||||
|
screen's own model: the UI entity there is a stack, not a card.
|
||||||
|
|
||||||
|
Neither stack has ever served this route. The Python oracle maps
|
||||||
|
`item/resource` method-agnostically to `defs_route`, so a PUT would get a
|
||||||
|
definition list and HTTP 200 while nothing was sold — the client would believe
|
||||||
|
the sale succeeded. On staging the oracle is deliberately dead, so it 502'd and
|
||||||
|
Core was left untouched (coins 29843976, owned 1993, consumables 17).
|
||||||
|
|
||||||
|
### Consequence for production
|
||||||
|
|
||||||
|
Production's oracle IS alive, so today a consumable quick-sell there would reach
|
||||||
|
Python, return 200 from `defs_route`, and mutate nothing — the client would show
|
||||||
|
a successful sale that never happened. That is a second, independent reason not
|
||||||
|
to quick-sell consumables in production until this route is implemented in Rust.
|
||||||
|
|
||||||
|
### UNKNOWN, not to be guessed
|
||||||
|
|
||||||
|
* Does an empty-body PUT sell ONE copy or the WHOLE stack? The request carries no
|
||||||
|
quantity, and both readings fit. A stack of 2 at 38 is either +38 or +76.
|
||||||
|
* Which owned instance is consumed when several share the resourceId.
|
||||||
|
* What response the client requires (the player path's ack shape may not apply).
|
||||||
|
|||||||
@@ -939,7 +939,18 @@ freezes any of these — GAPs are "feature missing", not "crash".
|
|||||||
| 4 | FutViewCards | `0x1801293d0` | GET `ut/%s/item` | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | HANDLED (utas `/item` `defs_route` serves `itemData`) | HIGH |
|
| 4 | FutViewCards | `0x1801293d0` | GET `ut/%s/item` | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | HANDLED (utas `/item` `defs_route` serves `itemData`) | HIGH |
|
||||||
| 5 | FutActivateCard | `0x1801642c0` | PUT `ut/%s/item` (FUT_CLUB_ACTIVATE_ITEM_DP) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
|
| 5 | FutActivateCard | `0x1801642c0` | PUT `ut/%s/item` (FUT_CLUB_ACTIVATE_ITEM_DP) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
|
||||||
| 6 | FutApplyCard | `0x18012a710` | PUT `ut/%s/item` (apply by itemId) | `itemData`(0x16b) → **array[updated card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
|
| 6 | FutApplyCard | `0x18012a710` | PUT `ut/%s/item` (apply by itemId) | `itemData`(0x16b) → **array[updated card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
|
||||||
| 7 | FutApplyCardByRes | `0x18012ad10` | PUT `ut/%s/item` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | GAP | HIGH |
|
| 7 | FutApplyCardByRes | `0x18012ad10` | **POST** `ut/%s/item/resource/<rid>` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | **SERVED** (Rust host, contracts + attribute training) | HIGH |
|
||||||
|
|
||||||
|
> **Rows 6 and 7 are NOT the same route.** `ApplyCardByRes` carries urlIndex
|
||||||
|
> `0x0e`, which resolves to `ut/%s/item/resource` — not `ut/%s/item`
|
||||||
|
> (`plan-2026-08-05-pack-opening.md:505-506`, shared with `DiscardCardByRes` and
|
||||||
|
> `MoveCardByRes`). The verb is **POST**, live-proven by a real-client capture:
|
||||||
|
> `POST /ut/game/fifa17/item/resource/5001004` `{"apply":[{"id":100000003}]}`.
|
||||||
|
> This row previously read `PUT ut/%s/item` for both, and that conflation is what
|
||||||
|
> kept the "apply must ride `PUT ut/%s/item`" hypothesis alive
|
||||||
|
> (`CLIENT_ROUTE_SURFACE.md:104-106`) until the POST capture settled it — every
|
||||||
|
> observed `PUT ut/%s/item` is a pile MOVE, never an apply.
|
||||||
|
|
||||||
| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH |
|
| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH |
|
||||||
| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH |
|
| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH |
|
||||||
| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH |
|
| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH |
|
||||||
|
|||||||
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()
|
||||||
@@ -90,7 +90,13 @@ kill_test 11 "FIFA wire item id stored in canonical replacement" \
|
|||||||
|
|
||||||
kill_test 12 "projector rebuilds items independently of shared shaper" \
|
kill_test 12 "projector rebuilds items independently of shared shaper" \
|
||||||
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
|
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
|
||||||
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
|
'"itemData": shape_item(
|
||||||
|
item,
|
||||||
|
id,
|
||||||
|
ent,
|
||||||
|
ident.discard_value(item),
|
||||||
|
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||||
|
),' '"itemData": json!({"id": id.item_id}),'
|
||||||
|
|
||||||
kill_test 13 "extension schema version ignored on read" \
|
kill_test 13 "extension schema version ignored on read" \
|
||||||
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
|
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
|
||||||
|
|||||||
@@ -41,9 +41,40 @@ pub struct Fifa17CardIdentity {
|
|||||||
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
|
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
|
||||||
/// the verified `fcc_kitcards.cardassetid` value (`35`).
|
/// the verified `fcc_kitcards.cardassetid` value (`35`).
|
||||||
pub card_asset_id: u32,
|
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
|
/// Source team id for a club kit, or a manager's real club. Zero for content
|
||||||
/// kinds that do not use it.
|
/// kinds that do not use it.
|
||||||
pub team_id: i64,
|
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.
|
/// Manager chemistry nation (`managercards.nation`), zero when unused.
|
||||||
///
|
///
|
||||||
/// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`)
|
/// 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`.
|
/// Separate card-art id for non-player definitions; absent → `asset_id`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
card_asset_id: Option<u32>,
|
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`.
|
/// Source team id for a kit or manager definition; absent → `0`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
team_id: Option<i64>,
|
team_id: Option<i64>,
|
||||||
@@ -207,6 +241,12 @@ struct RawCard {
|
|||||||
/// Contract-card grant (atom 0xb8); absent → key omitted.
|
/// Contract-card grant (atom 0xb8); absent → key omitted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
contract: Option<i64>,
|
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 {
|
fn default_rareflag() -> i64 {
|
||||||
@@ -264,7 +304,10 @@ impl Fifa17CardCatalog {
|
|||||||
kind: ContentKind::from_str(&rc.kind),
|
kind: ContentKind::from_str(&rc.kind),
|
||||||
subtype: rc.subtype,
|
subtype: rc.subtype,
|
||||||
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
|
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),
|
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),
|
nation: rc.nation.unwrap_or(0),
|
||||||
league_id: rc.league_id.unwrap_or(0),
|
league_id: rc.league_id.unwrap_or(0),
|
||||||
rating: rc.rating,
|
rating: rc.rating,
|
||||||
@@ -355,6 +398,49 @@ mod tests {
|
|||||||
assert_eq!(cat.lookup("card_missing"), None);
|
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
|
/// 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
|
/// 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.
|
/// a defaulted `contract` would invent the number of matches a card grants.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::fut::content_taxonomy::ContentKind;
|
use crate::fut::content_taxonomy::ContentKind;
|
||||||
|
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||||
use crate::fut::entities::ReverseEntityResolver;
|
use crate::fut::entities::ReverseEntityResolver;
|
||||||
use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
|
use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
|
||||||
use crate::fut::item_state;
|
use crate::fut::item_state;
|
||||||
@@ -54,7 +55,13 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
|||||||
match ident.kind_of(item) {
|
match ident.kind_of(item) {
|
||||||
ContentKind::Player => match ident.resolve(item) {
|
ContentKind::Player => match ident.resolve(item) {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
out.push(shape_item(item, id, ent, ident.discard_value(item)));
|
out.push(shape_item(
|
||||||
|
item,
|
||||||
|
id,
|
||||||
|
ent,
|
||||||
|
ident.discard_value(item),
|
||||||
|
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||||
|
));
|
||||||
stats.emitted += 1;
|
stats.emitted += 1;
|
||||||
}
|
}
|
||||||
None => stats.dropped_no_asset += 1,
|
None => stats.dropped_no_asset += 1,
|
||||||
@@ -83,7 +90,10 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
|||||||
// because a guard arm would not prove exhaustiveness).
|
// because a guard arm would not prove exhaustiveness).
|
||||||
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
|
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
out.push(shape_staff_item(id, STAFF_CONTRACT));
|
out.push(shape_staff_item(
|
||||||
|
id,
|
||||||
|
item.contract_matches.unwrap_or(STAFF_CONTRACT),
|
||||||
|
));
|
||||||
stats.emitted += 1;
|
stats.emitted += 1;
|
||||||
}
|
}
|
||||||
None => stats.dropped_no_asset += 1,
|
None => stats.dropped_no_asset += 1,
|
||||||
@@ -116,6 +126,7 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::fut::contract_cards::CONTRACT_MATCH_CAP;
|
||||||
use crate::fut::entities::Fifa17Entities;
|
use crate::fut::entities::Fifa17Entities;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -145,6 +156,11 @@ mod tests {
|
|||||||
league: league.into(),
|
league: league.into(),
|
||||||
club: club.into(),
|
club: club.into(),
|
||||||
attributes: [90, 88, 70, 85, 40, 78],
|
attributes: [90, 88, 70, 85, 40, 78],
|
||||||
|
// Untracked by default, so these fixtures exercise the pack-fresh
|
||||||
|
// fallback; a test that cares sets it explicitly.
|
||||||
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +377,11 @@ mod tests {
|
|||||||
assert_eq!(coach["resourceId"], 3000083);
|
assert_eq!(coach["resourceId"], 3000083);
|
||||||
assert_eq!(coach["cardsubtypeid"], 8);
|
assert_eq!(coach["cardsubtypeid"], 8);
|
||||||
assert_eq!(coach["itemType"], "staff");
|
assert_eq!(coach["itemType"], "staff");
|
||||||
assert_eq!(coach["contract"], STAFF_CONTRACT);
|
assert_eq!(
|
||||||
|
coach["contract"], STAFF_CONTRACT,
|
||||||
|
"this fixture is UNTRACKED (contract_matches None), so the wire shows \
|
||||||
|
the pack-fresh fallback — not because the shaper hardcodes it"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
coach.get("nation").is_none()
|
coach.get("nation").is_none()
|
||||||
&& coach.get("leagueId").is_none()
|
&& coach.get("leagueId").is_none()
|
||||||
@@ -411,7 +431,10 @@ mod tests {
|
|||||||
assert_eq!(mgr["nation"], 45);
|
assert_eq!(mgr["nation"], 45);
|
||||||
assert_eq!(mgr["leagueId"], 53);
|
assert_eq!(mgr["leagueId"], 53);
|
||||||
assert_eq!(mgr["teamid"], 241);
|
assert_eq!(mgr["teamid"], 241);
|
||||||
assert_eq!(mgr["contract"], STAFF_CONTRACT);
|
assert_eq!(
|
||||||
|
mgr["contract"], STAFF_CONTRACT,
|
||||||
|
"untracked fixture => pack-fresh fallback"
|
||||||
|
);
|
||||||
assert_eq!(mgr["itemState"], "free");
|
assert_eq!(mgr["itemState"], "free");
|
||||||
assert_eq!(mgr["owners"], 1);
|
assert_eq!(mgr["owners"], 1);
|
||||||
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
|
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
|
||||||
@@ -422,6 +445,78 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `/club` is the screen a contract apply is judged on: if the envelope keeps
|
||||||
|
/// reporting the pack-fresh count, a committed apply is invisible and the
|
||||||
|
/// operator sees a 200 that did nothing. Both families must carry the number
|
||||||
|
/// Core persisted.
|
||||||
|
#[test]
|
||||||
|
fn club_reports_the_contract_core_persisted_for_players_and_staff() {
|
||||||
|
let ent = entities();
|
||||||
|
let ident = KindMapIdentity {
|
||||||
|
ids: HashMap::from([(
|
||||||
|
"card_player".to_string(),
|
||||||
|
Fifa17Identity {
|
||||||
|
item_id: 100000001,
|
||||||
|
asset_id: 20801,
|
||||||
|
resource_id: 20801,
|
||||||
|
rareflag: 1,
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
kits: HashMap::new(),
|
||||||
|
staff: HashMap::from([(
|
||||||
|
"card_manager".to_string(),
|
||||||
|
Fifa17StaffIdentity {
|
||||||
|
item_id: 100004871,
|
||||||
|
resource_id: 1000509,
|
||||||
|
subtype: 4,
|
||||||
|
nation: 45,
|
||||||
|
league_id: 53,
|
||||||
|
team_id: 241,
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
kinds: HashMap::from([
|
||||||
|
("card_player".to_string(), ContentKind::Player),
|
||||||
|
("card_manager".to_string(), ContentKind::Staff),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
// A player mid-way through its contracts, a fully topped-up manager, and
|
||||||
|
// one untracked player that must fall back.
|
||||||
|
let mut played = item(
|
||||||
|
"oc-played",
|
||||||
|
"card_player",
|
||||||
|
86,
|
||||||
|
"ST",
|
||||||
|
"Argentina",
|
||||||
|
"Premier League",
|
||||||
|
"Chelsea",
|
||||||
|
);
|
||||||
|
played.contract_matches = Some(3);
|
||||||
|
let mut manager = item("oc-mgr", "card_manager", 0, "", "", "", "");
|
||||||
|
manager.contract_matches = Some(CONTRACT_MATCH_CAP);
|
||||||
|
let untracked = item(
|
||||||
|
"oc-fresh",
|
||||||
|
"card_player",
|
||||||
|
86,
|
||||||
|
"ST",
|
||||||
|
"Argentina",
|
||||||
|
"Premier League",
|
||||||
|
"Chelsea",
|
||||||
|
);
|
||||||
|
|
||||||
|
let (body, stats) = shape_club_response(&[played, manager, untracked], &ent, &ident);
|
||||||
|
assert_eq!(stats.emitted, 3);
|
||||||
|
let arr = body["itemData"].as_array().unwrap();
|
||||||
|
assert_eq!(arr[0]["contract"], 3, "the player's persisted count");
|
||||||
|
assert_eq!(
|
||||||
|
arr[1]["contract"], CONTRACT_MATCH_CAP,
|
||||||
|
"staff read the same persisted field, not STAFF_CONTRACT"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
arr[2]["contract"], PACK_FRESH_CONTRACT_MATCHES,
|
||||||
|
"only an untracked instance falls back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kits_project_with_owned_active_home_and_away_states() {
|
fn kits_project_with_owned_active_home_and_away_states() {
|
||||||
let ent = entities();
|
let ent = entities();
|
||||||
@@ -432,6 +527,8 @@ mod tests {
|
|||||||
card_asset_id: 35,
|
card_asset_id: 35,
|
||||||
subtype: 9,
|
subtype: 9,
|
||||||
team_id,
|
team_id,
|
||||||
|
category: 2,
|
||||||
|
year: 0,
|
||||||
};
|
};
|
||||||
let ident = KindMapIdentity {
|
let ident = KindMapIdentity {
|
||||||
ids: HashMap::new(),
|
ids: HashMap::new(),
|
||||||
@@ -466,7 +563,7 @@ mod tests {
|
|||||||
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
|
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
|
||||||
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
|
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
|
||||||
assert!(body["itemData"][0].get("attributeList").is_none());
|
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.
|
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
|
||||||
@@ -484,6 +581,8 @@ mod tests {
|
|||||||
card_asset_id: art,
|
card_asset_id: art,
|
||||||
subtype,
|
subtype,
|
||||||
team_id: 21,
|
team_id: 21,
|
||||||
|
category: 2,
|
||||||
|
year: 0,
|
||||||
};
|
};
|
||||||
let ident = KindMapIdentity {
|
let ident = KindMapIdentity {
|
||||||
ids: HashMap::new(),
|
ids: HashMap::new(),
|
||||||
|
|||||||
@@ -33,8 +33,40 @@
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::fut::discard;
|
||||||
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
|
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
|
||||||
|
|
||||||
|
/// The stack's `discardValue` (atom 0xd7) — the number the consumables screen
|
||||||
|
/// DISPLAYS, per card.
|
||||||
|
///
|
||||||
|
/// This used to be hard-coded `0`, on the theory that the client would compute
|
||||||
|
/// the price itself from `fcc_discardcoins` the way it does for a card whose
|
||||||
|
/// `discardValue` we omit. That theory was wrong, and the screen showed
|
||||||
|
/// "Quick sell for 0 coins" on a real production club (operator-observed,
|
||||||
|
/// 2026-08-22) while Core would have paid 3/13/32 for those same contracts.
|
||||||
|
///
|
||||||
|
/// Why the old reasoning failed, from evidence rather than re-derivation:
|
||||||
|
///
|
||||||
|
/// * `item+0x38` (the `discardValue` we send) non-zero makes the client SKIP its
|
||||||
|
/// local computation and display our number — live-proven again on the
|
||||||
|
/// production client, 16/16 resident cards `SERVER-SHOWN`.
|
||||||
|
/// * We send no `discardValue` inside a consumable's `item`, so `+0x38` is 0 and
|
||||||
|
/// the client's local computation DOES run, filling `+0x3c` with the right
|
||||||
|
/// value — Milestone 1 measured exactly that (3/3/32/38, matching this table).
|
||||||
|
/// * The screen nonetheless showed 0. So the screen is not reading the item's
|
||||||
|
/// computed `+0x3c`; it reads the STACK's atom 0xd7, which we were sending as
|
||||||
|
/// 0.
|
||||||
|
///
|
||||||
|
/// So the value belongs here, and it is the SAME number
|
||||||
|
/// [`discard::value_for_definition`] gives the quick-sell payout — one source, so
|
||||||
|
/// the screen and the wallet cannot disagree. Per CARD, not per stack: FUT
|
||||||
|
/// prices a card, and the stack is only a quantity badge over identical copies.
|
||||||
|
///
|
||||||
|
/// `None` (definition not priceable) stays `0` rather than inventing a number.
|
||||||
|
fn stack_discard_value(id: &Fifa17ConsumableIdentity) -> i64 {
|
||||||
|
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the consumables-screen body from the club's owned consumable copies.
|
/// Build the consumables-screen body from the club's owned consumable copies.
|
||||||
///
|
///
|
||||||
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
|
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
|
||||||
@@ -43,10 +75,10 @@ use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeSta
|
|||||||
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
|
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
|
||||||
/// different item than the club owns is worse than omitting the stack.
|
/// different item than the club owns is worse than omitting the stack.
|
||||||
///
|
///
|
||||||
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
|
/// `discardValue` carries the card's real quick-sell price — see
|
||||||
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
|
/// [`stack_discard_value`]. It used to be `0` on the theory that the client
|
||||||
/// live-proven oracle sends on this route. Inventing a price from the player
|
/// priced the card itself; the production screen showed "Quick sell for 0 coins"
|
||||||
/// quick-sell table would be a fabricated number the client does not need.
|
/// instead, so the stack atom is what the screen reads.
|
||||||
///
|
///
|
||||||
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
|
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
|
||||||
/// later item operation on the stack therefore addresses a card the club really
|
/// later item operation on the stack therefore addresses a card the club really
|
||||||
@@ -78,7 +110,7 @@ pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, Shape
|
|||||||
order.push(id.resource_id);
|
order.push(id.resource_id);
|
||||||
stacks.push(json!({
|
stacks.push(json!({
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"discardValue": 0,
|
"discardValue": stack_discard_value(id),
|
||||||
"item": shape_consumable_item(*id),
|
"item": shape_consumable_item(*id),
|
||||||
"resourceId": id.resource_id,
|
"resourceId": id.resource_id,
|
||||||
"untradeableCount": i64::from(id.untradeable),
|
"untradeableCount": i64::from(id.untradeable),
|
||||||
@@ -109,6 +141,80 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A contract card of the given subtype/rating — the family the production
|
||||||
|
/// screen showed as "0 coins".
|
||||||
|
fn contract(
|
||||||
|
item_id: u32,
|
||||||
|
resource_id: u32,
|
||||||
|
subtype: i64,
|
||||||
|
rating: u8,
|
||||||
|
) -> Fifa17ConsumableIdentity {
|
||||||
|
Fifa17ConsumableIdentity {
|
||||||
|
item_id,
|
||||||
|
resource_id,
|
||||||
|
asset_id: resource_id,
|
||||||
|
card_asset_id: 7,
|
||||||
|
subtype,
|
||||||
|
rareflag: 0,
|
||||||
|
rating,
|
||||||
|
amount: None,
|
||||||
|
contract: Some(1),
|
||||||
|
untradeable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stack atom the screen reads MUST carry the same number the quick-sell
|
||||||
|
/// pays. A production club displayed "Quick sell for 0 coins" for contracts
|
||||||
|
/// Core would have paid 3/13/32 for; nothing may reintroduce that gap.
|
||||||
|
#[test]
|
||||||
|
fn stack_discard_value_is_the_payout_and_never_a_silent_zero() {
|
||||||
|
// The three contracts owned by the real production club.
|
||||||
|
let items = vec![
|
||||||
|
contract(1, 5_001_004, 201, 60),
|
||||||
|
contract(2, 5_001_008, 202, 65),
|
||||||
|
contract(3, 5_001_009, 202, 80),
|
||||||
|
];
|
||||||
|
let (body, _) = consumables_response(&items);
|
||||||
|
let stacks = body["itemData"].as_array().unwrap();
|
||||||
|
assert_eq!(stacks.len(), 3);
|
||||||
|
for (stack, id) in stacks.iter().zip(items.iter()) {
|
||||||
|
let shown = stack["discardValue"].as_i64().unwrap();
|
||||||
|
let paid =
|
||||||
|
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating)
|
||||||
|
.expect("a contract definition is priceable");
|
||||||
|
assert_eq!(
|
||||||
|
shown, paid,
|
||||||
|
"displayed must equal payout for {}",
|
||||||
|
id.resource_id
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shown > 0,
|
||||||
|
"{} priced at 0 is the bug we just fixed",
|
||||||
|
id.resource_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The exact recovered values, so a table regression is visible here too.
|
||||||
|
assert_eq!(stacks[0]["discardValue"], 3);
|
||||||
|
assert_eq!(stacks[1]["discardValue"], 13);
|
||||||
|
assert_eq!(stacks[2]["discardValue"], 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapsing copies must not multiply the price: FUT prices a CARD, and the
|
||||||
|
/// stack is a quantity badge over identical copies.
|
||||||
|
#[test]
|
||||||
|
fn stack_discard_value_is_per_card_not_per_stack() {
|
||||||
|
let items = vec![
|
||||||
|
contract(1, 5_001_009, 202, 80),
|
||||||
|
contract(2, 5_001_009, 202, 80),
|
||||||
|
contract(3, 5_001_009, 202, 80),
|
||||||
|
];
|
||||||
|
let (body, _) = consumables_response(&items);
|
||||||
|
let stacks = body["itemData"].as_array().unwrap();
|
||||||
|
assert_eq!(stacks.len(), 1);
|
||||||
|
assert_eq!(stacks[0]["count"], 3);
|
||||||
|
assert_eq!(stacks[0]["discardValue"], 32, "per card, not 3 x 32");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn identical_copies_collapse_into_one_counted_stack() {
|
fn identical_copies_collapse_into_one_counted_stack() {
|
||||||
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
|
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
|
||||||
|
|||||||
@@ -218,18 +218,55 @@ pub fn consumable_needs(family: &str) -> ConsumableNeeds {
|
|||||||
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
|
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
|
||||||
/// and this route is the door).
|
/// and this route is the door).
|
||||||
///
|
///
|
||||||
/// The segment names are the consumable UI group table at `0x180203260` (seven
|
/// The segment names are the client's own CONSUMABLE_TYPE→segment switch,
|
||||||
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
|
/// recovered live 2026-08-22 from CardsDLL: the literal table at `0x1801f5a38`
|
||||||
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
|
/// (under `MyClubAdapterClass`/`CONSUMABLE_TYPE`) and the jump table at
|
||||||
/// on the wire and the singular `contract` is accepted because the client has
|
/// `0x180048820`, which indexes by `enum + 1` through the byte table at
|
||||||
/// used both spellings. Segments are matched lower-cased.
|
/// `0x180048a90`. Nine segments, not seven:
|
||||||
///
|
///
|
||||||
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
|
/// | enum | segment |
|
||||||
/// correspondence is checkable against the panel: training→42, contracts→13,
|
/// |------|---------|
|
||||||
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
|
/// | -1 (unset) | `development` |
|
||||||
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
|
/// | 1, 2 | `contracts` |
|
||||||
/// NO group code, so no segment can reach them — that is the client's own gap,
|
/// | 3 | `healing` |
|
||||||
/// not an omission here.
|
/// | 4 | `fitness` |
|
||||||
|
/// | 16 | `formation` |
|
||||||
|
/// | 17 | `position` |
|
||||||
|
/// | 23 | `playStyle` |
|
||||||
|
/// | 24 | `managerLeagueModifier` |
|
||||||
|
/// | 0, 5..15, 18..22 | `training` (the switch default) |
|
||||||
|
///
|
||||||
|
/// This CORRECTS the previous note here, which read the seven-code UI group
|
||||||
|
/// table at `0x180203260` and concluded the two formation-modifier families
|
||||||
|
/// "have NO group code, so no segment can reach them — the client's own gap".
|
||||||
|
/// The client does have a `formation` segment (enum 16), and it asked for
|
||||||
|
/// `development` live, so both were server-side gaps, not client ones.
|
||||||
|
///
|
||||||
|
/// `development` is the **type-unset** bucket: index 0 of a table indexed by
|
||||||
|
/// `enum + 1`, i.e. no type filter was set. It is therefore the unfiltered view
|
||||||
|
/// and maps to every family — which is consistent, since the eight TYPED
|
||||||
|
/// segments already reach all thirteen families exactly once.
|
||||||
|
///
|
||||||
|
/// COMPETING INFERENCE, recorded rather than buried. `fut_consumables.py`'s
|
||||||
|
/// `TYPE_CATEGORIES` maps `development` to card-categories `{6,7,8,9,10}`
|
||||||
|
/// (formation/position/playstyle/manager-league) — i.e. the modifier families
|
||||||
|
/// only, not everything. That grouping is explicitly flagged there as INFERRED
|
||||||
|
/// from `FUN_180048780`'s UI-bucket names, with "the tab-to-arm binding has
|
||||||
|
/// NEVER been observed on the wire".
|
||||||
|
///
|
||||||
|
/// They are not the same enum: the oracle's is the 0..10 CARD-category space of
|
||||||
|
/// `FUN_18013f4d0`, this is the 0..24 CONSUMABLE_TYPE space that actually
|
||||||
|
/// produces the URL segment. The tiebreaker is the switch itself — it gives
|
||||||
|
/// formation (16), position (17), playStyle (23) and managerLeagueModifier (24)
|
||||||
|
/// their OWN segment strings, so those types are not folded into `development`,
|
||||||
|
/// which is what the oracle's grouping would require. The unfiltered reading is
|
||||||
|
/// therefore the better-supported one, but it is still a reading: what the
|
||||||
|
/// SCREEN expects to list has not been observed, and one live capture of the
|
||||||
|
/// development tab would settle it.
|
||||||
|
///
|
||||||
|
/// `training` and `contracts` are CONFIRMED on the wire, `development` was
|
||||||
|
/// observed live, and the singular `contract` is accepted because the client has
|
||||||
|
/// used both spellings. Segments are matched lower-cased.
|
||||||
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
|
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
|
||||||
Some(match segment {
|
Some(match segment {
|
||||||
"training" => &["gk_training", "player_training"],
|
"training" => &["gk_training", "player_training"],
|
||||||
@@ -239,10 +276,31 @@ pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'sta
|
|||||||
"position" => &["position_mod"],
|
"position" => &["position_mod"],
|
||||||
"playstyle" => &["player_playstyle", "gk_playstyle"],
|
"playstyle" => &["player_playstyle", "gk_playstyle"],
|
||||||
"managerleaguemodifier" => &["manager_league"],
|
"managerleaguemodifier" => &["manager_league"],
|
||||||
|
"formation" => &["manager_formation_mod", "formation_mod"],
|
||||||
|
"development" => ALL_CONSUMABLE_FAMILIES,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every consumable family, i.e. the `development` (type-unset) view. Kept as one
|
||||||
|
/// list so a new family cannot be added to the taxonomy and silently omitted from
|
||||||
|
/// the unfiltered screen.
|
||||||
|
pub const ALL_CONSUMABLE_FAMILIES: &[&str] = &[
|
||||||
|
"gk_training",
|
||||||
|
"player_training",
|
||||||
|
"player_contract",
|
||||||
|
"manager_contract",
|
||||||
|
"player_fitness",
|
||||||
|
"squad_fitness",
|
||||||
|
"healing",
|
||||||
|
"position_mod",
|
||||||
|
"player_playstyle",
|
||||||
|
"gk_playstyle",
|
||||||
|
"manager_league",
|
||||||
|
"manager_formation_mod",
|
||||||
|
"formation_mod",
|
||||||
|
];
|
||||||
|
|
||||||
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
|
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
|
||||||
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
|
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
|
||||||
///
|
///
|
||||||
@@ -430,7 +488,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn consumable_route_categories_partition_the_reachable_families() {
|
fn consumable_route_categories_partition_the_reachable_families() {
|
||||||
// The seven group codes, plus the singular `contract` spelling.
|
// The eight TYPED segments of the client's own switch (enum 1,2,3,4,16,
|
||||||
|
// 17,23,24 plus the default), and the singular `contract` spelling.
|
||||||
let segments = [
|
let segments = [
|
||||||
"training",
|
"training",
|
||||||
"contracts",
|
"contracts",
|
||||||
@@ -439,6 +498,7 @@ mod tests {
|
|||||||
"position",
|
"position",
|
||||||
"playstyle",
|
"playstyle",
|
||||||
"managerleaguemodifier",
|
"managerleaguemodifier",
|
||||||
|
"formation",
|
||||||
];
|
];
|
||||||
let mut seen: Vec<&str> = Vec::new();
|
let mut seen: Vec<&str> = Vec::new();
|
||||||
for seg in segments {
|
for seg in segments {
|
||||||
@@ -452,22 +512,27 @@ mod tests {
|
|||||||
consumable_families_for_category("contracts"),
|
consumable_families_for_category("contracts"),
|
||||||
"both spellings the client has used mean the same set"
|
"both spellings the client has used mean the same set"
|
||||||
);
|
);
|
||||||
// Eleven of the thirteen families are reachable; the two formation
|
// All THIRTEEN families are reachable: the client does have a `formation`
|
||||||
// modifiers have no group code in the client's own table.
|
// segment (enum 16), so the two formation modifiers were a server-side
|
||||||
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
|
// gap, not the client gap this test used to assert.
|
||||||
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
|
assert_eq!(seen.len(), 13, "no duplicates: {seen:?}");
|
||||||
|
for subtype in [51, 61, 71, 91, 121, 201, 202, 211, 219, 220, 250, 269, 300] {
|
||||||
let (family, _) = consumable_family(subtype).unwrap();
|
let (family, _) = consumable_family(subtype).unwrap();
|
||||||
assert!(seen.contains(&family), "no category serves {family}");
|
assert!(seen.contains(&family), "no category serves {family}");
|
||||||
}
|
}
|
||||||
for unreachable in [71, 121] {
|
// `development` is the type-UNSET bucket (index 0 of an `enum + 1` table),
|
||||||
let (family, _) = consumable_family(unreachable).unwrap();
|
// i.e. the unfiltered view. It deliberately overlaps the typed segments,
|
||||||
assert!(
|
// and must stay exactly the union of them so a new family cannot be added
|
||||||
!seen.contains(&family),
|
// to the taxonomy and silently vanish from the unfiltered screen.
|
||||||
"{family} has no group code; claiming it would invent a segment"
|
let mut dev = consumable_families_for_category("development")
|
||||||
);
|
.unwrap()
|
||||||
}
|
.to_vec();
|
||||||
|
dev.sort_unstable();
|
||||||
|
let mut all = seen.clone();
|
||||||
|
all.sort_unstable();
|
||||||
|
assert_eq!(dev, all, "development must be exactly the unfiltered set");
|
||||||
// Not a consumables segment (and NOT a `?type=` token either).
|
// Not a consumables segment (and NOT a `?type=` token either).
|
||||||
for s in ["", "player", "kit", "Training", "development"] {
|
for s in ["", "player", "kit", "Training"] {
|
||||||
assert!(
|
assert!(
|
||||||
consumable_families_for_category(s).is_none(),
|
consumable_families_for_category(s).is_none(),
|
||||||
"{s:?} is not a consumable category"
|
"{s:?} is not a consumable category"
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! FIFA 17 **contract consumables** — the shipped EA grant table.
|
||||||
|
//!
|
||||||
|
//! A contract card adds match-contracts to a TARGET card. The number granted is
|
||||||
|
//! selected by two keys: the consumable's own `resourceId` (which card it is)
|
||||||
|
//! and the **TARGET's** rating tier (bronze/silver/gold). The target's tier, not
|
||||||
|
//! the card's — a gold contract card dropped on a bronze player grants the
|
||||||
|
//! BRONZE column. Getting that backwards silently mis-credits every apply, so it
|
||||||
|
//! is stated here as the module's first invariant.
|
||||||
|
//!
|
||||||
|
//! ## Provenance
|
||||||
|
//!
|
||||||
|
//! [`CONTRACT_CARDS`] is the shipped EA table `fcc_contractcards`, transcribed
|
||||||
|
//! verbatim. It was cross-validated cell by cell against the published FIFA 17
|
||||||
|
//! contract matrix: **36 of 36 cells agree** (12 cards × 3 tiers; the 99-special
|
||||||
|
//! is not part of the published matrix). That is the whole basis for these
|
||||||
|
//! numbers — do not compute, interpolate or "correct" them. The table is
|
||||||
|
//! deliberately NOT monotonic in the target's tier: `5001003` grants 15 to a
|
||||||
|
//! bronze target, 11 to a silver one and 13 to a gold one. A "fix" that made it
|
||||||
|
//! monotonic would be an invention.
|
||||||
|
//!
|
||||||
|
//! ## Why the server must own the effect
|
||||||
|
//!
|
||||||
|
//! No client binary reads this table. A full string scan of every `.exe` and
|
||||||
|
//! `.dll` in the FIFA 17 install finds `fcc_contractcards` referenced **nowhere**
|
||||||
|
//! — the client ships the rows but never queries them, so it cannot compute the
|
||||||
|
//! grant and cannot second-guess ours. The effect is therefore
|
||||||
|
//! server-authoritative, and this table is the only non-invented source for it.
|
||||||
|
//!
|
||||||
|
//! ## Deliberately NOT implemented
|
||||||
|
//!
|
||||||
|
//! The "stored managers give up to 50% bonus contracts" mechanic. Its rule is
|
||||||
|
//! UNKNOWN: we have neither the multiplier's rounding, nor which stored managers
|
||||||
|
//! count, nor whether it stacks. Guessing it would corrupt the proven part of the
|
||||||
|
//! grant, so it is absent rather than approximated. This note is the record; it
|
||||||
|
//! is not a TODO, and nothing here reserves a hook for it.
|
||||||
|
//!
|
||||||
|
//! ## Family gating
|
||||||
|
//!
|
||||||
|
//! [`PLAYER_CONTRACT_SUBTYPE`] (201) applies to PLAYERS only and
|
||||||
|
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS only. A 202 target's tier comes
|
||||||
|
//! from [`staff_tier`], whose input is Core's authored definition rating for the
|
||||||
|
//! staff card (EA's `value` column). When Core carries none, [`staff_tier`]
|
||||||
|
//! answers `None` and the caller must REFUSE the apply — inventing gold (or
|
||||||
|
//! bronze, or the card's own tier) would silently pay out the wrong number with
|
||||||
|
//! no error anywhere.
|
||||||
|
//!
|
||||||
|
//! A contract also cannot be applied to a LOAN item. This crate models no loan
|
||||||
|
//! state, so that gate — like the 201/202 family check — is the caller's: this
|
||||||
|
//! module answers only "how many matches does card X grant a tier-Y target".
|
||||||
|
|
||||||
|
/// `(resource_id, [bronze, silver, gold])` — the grant a contract card makes to
|
||||||
|
/// a target of each tier, keyed by the consumable's FIFA `resourceId`.
|
||||||
|
///
|
||||||
|
/// Rows `5001001`–`5001006` are player contracts (`cardsubtypeid` 201),
|
||||||
|
/// `5001007`–`5001012` their manager counterparts (202), and `5001013` is the
|
||||||
|
/// EASFC 99-contract special (201). Sorted by `resource_id`; keys are unique.
|
||||||
|
const CONTRACT_CARDS: [(u32, [i64; 3]); 13] = [
|
||||||
|
(5_001_001, [8, 2, 1]),
|
||||||
|
(5_001_002, [10, 10, 8]),
|
||||||
|
(5_001_003, [15, 11, 13]),
|
||||||
|
(5_001_004, [15, 6, 3]),
|
||||||
|
(5_001_005, [20, 24, 18]),
|
||||||
|
(5_001_006, [28, 24, 28]),
|
||||||
|
(5_001_007, [8, 2, 1]),
|
||||||
|
(5_001_008, [8, 10, 8]),
|
||||||
|
(5_001_009, [11, 11, 13]),
|
||||||
|
(5_001_010, [15, 6, 3]),
|
||||||
|
(5_001_011, [18, 24, 18]),
|
||||||
|
(5_001_012, [24, 24, 28]),
|
||||||
|
(5_001_013, [99, 99, 99]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Which column of [`CONTRACT_CARDS`] a target's rating selects.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ContractTier {
|
||||||
|
Bronze,
|
||||||
|
Silver,
|
||||||
|
Gold,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContractTier {
|
||||||
|
/// Column index into a [`CONTRACT_CARDS`] row.
|
||||||
|
const fn column(self) -> usize {
|
||||||
|
match self {
|
||||||
|
ContractTier::Bronze => 0,
|
||||||
|
ContractTier::Silver => 1,
|
||||||
|
ContractTier::Gold => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tier's lowercase log token. The three names are the game's own tier
|
||||||
|
/// names, so a log line reads the same as the screen.
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ContractTier::Bronze => "bronze",
|
||||||
|
ContractTier::Silver => "silver",
|
||||||
|
ContractTier::Gold => "gold",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card tier from a rating: gold `>= 75`, silver `65..=74`, bronze `< 65`.
|
||||||
|
///
|
||||||
|
/// These are the client's OWN card-level thresholds, not cutoffs chosen here:
|
||||||
|
/// they are the same ladder [`super::discard::discard_level`] reads to key
|
||||||
|
/// `fcc_discardcoins` (`3` if `>= 75`, `2` if `65..=74`, else `1`). The two
|
||||||
|
/// tables index the same three tiers, so a rating that prices as gold also
|
||||||
|
/// contracts as gold.
|
||||||
|
pub fn tier_for_rating(rating: u8) -> ContractTier {
|
||||||
|
if rating >= 75 {
|
||||||
|
ContractTier::Gold
|
||||||
|
} else if rating >= 65 {
|
||||||
|
ContractTier::Silver
|
||||||
|
} else {
|
||||||
|
ContractTier::Bronze
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tier of a STAFF target. `None` when Core carries no authoritative value —
|
||||||
|
/// the caller MUST fail closed and never substitute a tier.
|
||||||
|
///
|
||||||
|
/// `source_rating` is EA's authored `value` for the staff definition, i.e. the
|
||||||
|
/// number the CLIENT ITSELF re-rates the card to: it merges a staff record from
|
||||||
|
/// its own `managercards`/`headcoachcards`/`fitnesscoachcards`/`physiocards`/
|
||||||
|
/// `gkcoachcards` table keyed on `carddbid`, ignoring whatever `rating` the
|
||||||
|
/// server sent. Core's `overall` is deliberately 0 for a non-player (it feeds
|
||||||
|
/// pricing and projection), so `overall` is NOT the tier source and must not be
|
||||||
|
/// read as one.
|
||||||
|
///
|
||||||
|
/// The ladder is [`tier_for_rating`], unchanged and not re-thresholded here:
|
||||||
|
/// staff are LIVE-PROVEN to use the SAME ladder as players. `coach_probe.py` and
|
||||||
|
/// `discard_probe.py` agree 4/4 against the running client — manager `value` 88
|
||||||
|
/// re-rates to discard level 3 (gold) and coaches at `value` 66 to level 2
|
||||||
|
/// (silver), exactly as [`super::discard::discard_level`] scores a player.
|
||||||
|
pub fn staff_tier(source_rating: Option<u8>) -> Option<ContractTier> {
|
||||||
|
source_rating.map(tier_for_rating)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matches granted by contract consumable `resource_id` against a target of
|
||||||
|
/// `tier`.
|
||||||
|
///
|
||||||
|
/// `None` when `resource_id` is not a known contract card — the caller must
|
||||||
|
/// refuse, never substitute a floor or a neighbouring row. A consumable outside
|
||||||
|
/// the 13 rows has no proven grant, and an invented one is a silent mis-credit.
|
||||||
|
pub fn contract_grant(resource_id: u32, tier: ContractTier) -> Option<i64> {
|
||||||
|
CONTRACT_CARDS
|
||||||
|
.iter()
|
||||||
|
.find(|&&(id, _)| id == resource_id)
|
||||||
|
.map(|&(_, grants)| grants[tier.column()])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard ceiling on match-contracts held by one player or manager: `new =
|
||||||
|
/// min(99, current + grant)`. A card that would overflow the cap is not an
|
||||||
|
/// error — the surplus is simply lost, as in retail.
|
||||||
|
pub const CONTRACT_MATCH_CAP: i64 = 99;
|
||||||
|
|
||||||
|
/// Contracts a pack-fresh player or manager starts with.
|
||||||
|
///
|
||||||
|
/// This is the FIFA-side default for an instance Core tracks no contract for:
|
||||||
|
/// Core stores NULL for "untracked", and the game-specific number to substitute
|
||||||
|
/// lives here rather than in Core.
|
||||||
|
pub const PACK_FRESH_CONTRACT_MATCHES: i64 = 7;
|
||||||
|
|
||||||
|
/// `cardsubtypeid` of a PLAYER contract card. Applies to players only.
|
||||||
|
pub const PLAYER_CONTRACT_SUBTYPE: i64 = 201;
|
||||||
|
|
||||||
|
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers only; the
|
||||||
|
/// target's tier comes from [`staff_tier`] over Core's authored staff rating.
|
||||||
|
pub const MANAGER_CONTRACT_SUBTYPE: i64 = 202;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The three tiers are the client's own rating ladder, so the boundaries are
|
||||||
|
/// exact: 64/65 and 74/75. An off-by-one here reads the wrong COLUMN and
|
||||||
|
/// silently grants the wrong number.
|
||||||
|
#[test]
|
||||||
|
fn tier_boundaries_are_the_clients_own_thresholds() {
|
||||||
|
assert_eq!(tier_for_rating(0), ContractTier::Bronze);
|
||||||
|
assert_eq!(tier_for_rating(64), ContractTier::Bronze);
|
||||||
|
assert_eq!(tier_for_rating(65), ContractTier::Silver);
|
||||||
|
assert_eq!(tier_for_rating(74), ContractTier::Silver);
|
||||||
|
assert_eq!(tier_for_rating(75), ContractTier::Gold);
|
||||||
|
assert_eq!(tier_for_rating(99), ContractTier::Gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A staff target Core carries no authored rating for has NO tier. `None` is
|
||||||
|
/// what lets the caller refuse; defaulting to bronze would silently under-pay
|
||||||
|
/// a gold manager, and defaulting to gold would over-pay every unknown one.
|
||||||
|
#[test]
|
||||||
|
fn an_unrated_staff_target_has_no_tier() {
|
||||||
|
assert_eq!(staff_tier(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staff read the SAME ladder as players, so the boundaries are the same
|
||||||
|
/// exact 64/65 and 74/75 — `staff_tier` must not re-threshold.
|
||||||
|
#[test]
|
||||||
|
fn staff_tier_boundaries_are_the_player_ladder() {
|
||||||
|
assert_eq!(staff_tier(Some(64)), Some(ContractTier::Bronze));
|
||||||
|
assert_eq!(staff_tier(Some(65)), Some(ContractTier::Silver));
|
||||||
|
assert_eq!(staff_tier(Some(74)), Some(ContractTier::Silver));
|
||||||
|
assert_eq!(staff_tier(Some(75)), Some(ContractTier::Gold));
|
||||||
|
for rating in 0..=99u8 {
|
||||||
|
assert_eq!(
|
||||||
|
staff_tier(Some(rating)),
|
||||||
|
Some(tier_for_rating(rating)),
|
||||||
|
"rating {rating} must not diverge from the shared ladder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two values the live client was actually observed re-rating: the
|
||||||
|
/// squad manager at `value` 88 scored discard level 3 (gold) and the coaches
|
||||||
|
/// at `value` 66 scored level 2 (silver), 4/4 across `coach_probe.py` and
|
||||||
|
/// `discard_probe.py`. These are the ONLY staff tiers with live proof, so
|
||||||
|
/// they are pinned here rather than left to the generic boundary test.
|
||||||
|
#[test]
|
||||||
|
fn the_live_probed_staff_values_score_their_observed_tiers() {
|
||||||
|
assert_eq!(staff_tier(Some(88)), Some(ContractTier::Gold), "manager 88");
|
||||||
|
assert_eq!(staff_tier(Some(66)), Some(ContractTier::Silver), "coach 66");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tier ladder must stay locked to the discard ladder it was taken from:
|
||||||
|
/// both index the same three card tiers, and a divergence would mean one of
|
||||||
|
/// the two is no longer the client's.
|
||||||
|
#[test]
|
||||||
|
fn the_tier_ladder_is_the_discard_ladder() {
|
||||||
|
for rating in 0..=99u8 {
|
||||||
|
let expected = match crate::fut::discard::discard_level(rating) {
|
||||||
|
1 => ContractTier::Bronze,
|
||||||
|
2 => ContractTier::Silver,
|
||||||
|
_ => ContractTier::Gold,
|
||||||
|
};
|
||||||
|
assert_eq!(tier_for_rating(rating), expected, "rating {rating}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spot-check across both families, including the row that proves the table
|
||||||
|
/// is not monotonic and the 99-special that has no published row.
|
||||||
|
#[test]
|
||||||
|
fn grant_matrix_cells_are_the_shipped_ea_values() {
|
||||||
|
// Player contracts.
|
||||||
|
assert_eq!(contract_grant(5_001_001, ContractTier::Bronze), Some(8));
|
||||||
|
assert_eq!(contract_grant(5_001_001, ContractTier::Gold), Some(1));
|
||||||
|
assert_eq!(contract_grant(5_001_004, ContractTier::Silver), Some(6));
|
||||||
|
assert_eq!(contract_grant(5_001_006, ContractTier::Silver), Some(24));
|
||||||
|
// Manager contracts. 5001008 is where the two families diverge: the
|
||||||
|
// player row grants 10 to a bronze target, the manager row 8.
|
||||||
|
assert_eq!(contract_grant(5_001_002, ContractTier::Bronze), Some(10));
|
||||||
|
assert_eq!(contract_grant(5_001_008, ContractTier::Bronze), Some(8));
|
||||||
|
assert_eq!(contract_grant(5_001_011, ContractTier::Gold), Some(18));
|
||||||
|
// The EASFC special pays 99 on every tier.
|
||||||
|
for tier in [
|
||||||
|
ContractTier::Bronze,
|
||||||
|
ContractTier::Silver,
|
||||||
|
ContractTier::Gold,
|
||||||
|
] {
|
||||||
|
assert_eq!(contract_grant(5_001_013, tier), Some(99), "{tier:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The matrix is authored EA data, NOT a formula: `5001003` grants MORE to a
|
||||||
|
/// bronze target (15) than to a gold one (13), and dips at silver (11). Any
|
||||||
|
/// "corrected" monotonic table fails here.
|
||||||
|
#[test]
|
||||||
|
fn the_matrix_is_deliberately_not_monotonic() {
|
||||||
|
let bronze = contract_grant(5_001_003, ContractTier::Bronze).unwrap();
|
||||||
|
let silver = contract_grant(5_001_003, ContractTier::Silver).unwrap();
|
||||||
|
let gold = contract_grant(5_001_003, ContractTier::Gold).unwrap();
|
||||||
|
assert_eq!((bronze, silver, gold), (15, 11, 13));
|
||||||
|
assert!(bronze > gold, "bronze target out-grants gold on this row");
|
||||||
|
assert!(silver < gold, "and silver is the trough, not the middle");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A consumable outside the 13 contract rows has NO proven grant. Returning
|
||||||
|
/// `None` is what lets the caller refuse; a floor or a nearest-row guess
|
||||||
|
/// would be a silent mis-credit.
|
||||||
|
#[test]
|
||||||
|
fn a_non_contract_resource_id_has_no_grant() {
|
||||||
|
// 5003012 is a training card — a different consumable family entirely.
|
||||||
|
for tier in [
|
||||||
|
ContractTier::Bronze,
|
||||||
|
ContractTier::Silver,
|
||||||
|
ContractTier::Gold,
|
||||||
|
] {
|
||||||
|
assert_eq!(contract_grant(5_003_012, tier), None);
|
||||||
|
assert_eq!(contract_grant(0, tier), None);
|
||||||
|
assert_eq!(contract_grant(5_001_000, tier), None, "just below the run");
|
||||||
|
assert_eq!(contract_grant(5_001_014, tier), None, "just above the run");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The table is a lookup keyed on exact ids: 13 rows, no duplicates, sorted.
|
||||||
|
/// A duplicated key would make `find` silently prefer whichever came first.
|
||||||
|
#[test]
|
||||||
|
fn the_table_keys_are_unique_and_sorted() {
|
||||||
|
for pair in CONTRACT_CARDS.windows(2) {
|
||||||
|
assert!(pair[0].0 < pair[1].0, "{:?} then {:?}", pair[0], pair[1]);
|
||||||
|
}
|
||||||
|
assert_eq!(CONTRACT_CARDS.len(), 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every grant is a real number of matches within the cap — the cap can
|
||||||
|
/// truncate an ADDITION, but no single card grants more than a full card.
|
||||||
|
#[test]
|
||||||
|
fn every_grant_is_positive_and_within_the_cap() {
|
||||||
|
for (id, grants) in CONTRACT_CARDS {
|
||||||
|
for grant in grants {
|
||||||
|
assert!(
|
||||||
|
grant > 0 && grant <= CONTRACT_MATCH_CAP,
|
||||||
|
"{id} grants {grant}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ use serde_json::{json, Value};
|
|||||||
|
|
||||||
use crate::fut::content_taxonomy::{
|
use crate::fut::content_taxonomy::{
|
||||||
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
|
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
|
||||||
MANAGER_SUBTYPE,
|
MANAGER_SUBTYPE, STADIUM_SUBTYPE,
|
||||||
};
|
};
|
||||||
use crate::fut::entities::ReverseEntityResolver;
|
use crate::fut::entities::ReverseEntityResolver;
|
||||||
use crate::fut::item_state;
|
use crate::fut::item_state;
|
||||||
@@ -48,6 +48,20 @@ pub struct CoreOwnedItem {
|
|||||||
pub club: String,
|
pub club: String,
|
||||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||||
pub attributes: [u8; 6],
|
pub attributes: [u8; 6],
|
||||||
|
/// Match-contracts remaining on this instance, as persisted by Core.
|
||||||
|
/// `None` = Core tracks none, so the caller substitutes the pack-fresh
|
||||||
|
/// default ([`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] for a
|
||||||
|
/// player, [`STAFF_CONTRACT`] for staff). Core deliberately stores NULL for
|
||||||
|
/// "untracked" rather than seeding a number, so the game-specific default
|
||||||
|
/// stays on this side of the boundary.
|
||||||
|
pub contract_matches: Option<i64>,
|
||||||
|
/// EA's authored definition rating for a non-player, from Core. `None` = Core
|
||||||
|
/// tracks none; callers MUST fail closed rather than substitute a tier.
|
||||||
|
pub source_rating: Option<u8>,
|
||||||
|
/// Core's own `content_kind` token for this instance, verbatim. Distinct from
|
||||||
|
/// the adapter catalog's kind: Core calls the squad manager `manager` while the
|
||||||
|
/// catalog classifies it `staff` + subtype 4.
|
||||||
|
pub core_content_kind: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
||||||
@@ -80,6 +94,11 @@ pub struct Fifa17KitIdentity {
|
|||||||
pub card_asset_id: u32,
|
pub card_asset_id: u32,
|
||||||
pub subtype: i64,
|
pub subtype: i64,
|
||||||
pub team_id: 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
|
/// FIFA-side identity fields needed to render an owned staff card (manager or
|
||||||
@@ -294,11 +313,21 @@ pub fn legacy_discard_value(rating: u8) -> i64 {
|
|||||||
/// identical by construction. `id` is the owned instance's resolved FIFA
|
/// identical by construction. `id` is the owned instance's resolved FIFA
|
||||||
/// identity — pass the resolver's answer for *this* owned copy so two copies of
|
/// identity — pass the resolver's answer for *this* owned copy so two copies of
|
||||||
/// one definition stay distinct on the wire.
|
/// one definition stay distinct on the wire.
|
||||||
|
///
|
||||||
|
/// `discard_value` and `contract` are explicit scalars for the same reason: both
|
||||||
|
/// are per-instance numbers this shaper must not invent. `contract` used to be a
|
||||||
|
/// hardcoded `7`, which made every card look pack-fresh no matter how many
|
||||||
|
/// matches it had played or how many contracts had been applied to it. The
|
||||||
|
/// caller passes [`CoreOwnedItem::contract_matches`] resolved against
|
||||||
|
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] — the substitution for
|
||||||
|
/// Core's "untracked" NULL belongs to the caller, because the default is
|
||||||
|
/// FIFA-specific and Core stores no number to speak for it.
|
||||||
pub fn shape_item(
|
pub fn shape_item(
|
||||||
item: &CoreOwnedItem,
|
item: &CoreOwnedItem,
|
||||||
id: Fifa17Identity,
|
id: Fifa17Identity,
|
||||||
ent: &impl ReverseEntityResolver,
|
ent: &impl ReverseEntityResolver,
|
||||||
discard_value: i64,
|
discard_value: i64,
|
||||||
|
contract: i64,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let asset = id.asset_id;
|
let asset = id.asset_id;
|
||||||
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
||||||
@@ -334,12 +363,52 @@ pub fn shape_item(
|
|||||||
// "our own data showing through" bug the Python oracle fixed by forcing
|
// "our own data showing through" bug the Python oracle fixed by forcing
|
||||||
// this off for owned copies (item_def keeps `true`; instances do not).
|
// this off for owned copies (item_def keeps `true`; instances do not).
|
||||||
"untradeable": false,
|
"untradeable": false,
|
||||||
"contract": 7,
|
"contract": contract,
|
||||||
"fitness": 99,
|
"fitness": 99,
|
||||||
"discardValue": discard_value,
|
"discardValue": discard_value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)
|
/// 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
|
/// 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.
|
/// one of the `active*` designations the client deserializes to 100..104.
|
||||||
@@ -368,6 +437,7 @@ pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
|||||||
"assetId": id.asset_id,
|
"assetId": id.asset_id,
|
||||||
"cardassetid": id.card_asset_id,
|
"cardassetid": id.card_asset_id,
|
||||||
"cardsubtypeid": id.subtype,
|
"cardsubtypeid": id.subtype,
|
||||||
|
"itemType": club_item_type(id.subtype),
|
||||||
"itemState": item_state,
|
"itemState": item_state,
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": false,
|
"untradeable": false,
|
||||||
@@ -375,15 +445,32 @@ pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
|||||||
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
|
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
|
||||||
item["teamid"] = json!(id.team_id);
|
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
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Contracts remaining on an owned staff card.
|
/// Pack-fresh contracts on a staff card — the FALLBACK for an instance Core
|
||||||
|
/// tracks no contract for, no longer an unconditional constant.
|
||||||
///
|
///
|
||||||
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
|
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
|
||||||
/// refuses to start a match when the manager's has run out. Core does not model
|
/// refuses to start a match when the manager's has run out. A caller holding an
|
||||||
/// staff contracts, so this mirrors the constant [`shape_item`] already emits
|
/// owned item passes `item.contract_matches.unwrap_or(STAFF_CONTRACT)`, so a
|
||||||
/// for players rather than inventing a second, different default.
|
/// tracked staff instance now reports its real remaining matches and only an
|
||||||
|
/// untracked one falls back here. The value matches
|
||||||
|
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] rather than inventing a
|
||||||
|
/// second, different default for the staff families.
|
||||||
pub const STAFF_CONTRACT: i64 = 7;
|
pub const STAFF_CONTRACT: i64 = 7;
|
||||||
|
|
||||||
/// Build one FIFA 17 staff item (manager or coach).
|
/// Build one FIFA 17 staff item (manager or coach).
|
||||||
@@ -525,6 +612,7 @@ pub const CONSUMABLE_UNTRADEABLE: bool = true;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::fut::content_taxonomy::STADIUM_SUBTYPE;
|
use crate::fut::content_taxonomy::STADIUM_SUBTYPE;
|
||||||
|
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||||
use crate::fut::entities::Fifa17Entities;
|
use crate::fut::entities::Fifa17Entities;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -546,6 +634,12 @@ mod tests {
|
|||||||
league: "Premier League".into(),
|
league: "Premier League".into(),
|
||||||
club: "Chelsea".into(),
|
club: "Chelsea".into(),
|
||||||
attributes: [90, 88, 70, 85, 40, 78],
|
attributes: [90, 88, 70, 85, 40, 78],
|
||||||
|
// Untracked by default; the contract tests below set it explicitly.
|
||||||
|
contract_matches: None,
|
||||||
|
// Players: their rating IS `overall`, so Core carries no separate
|
||||||
|
// authored definition rating, and these fixtures are player items.
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +656,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
&ent,
|
&ent,
|
||||||
legacy_discard_value(86),
|
legacy_discard_value(86),
|
||||||
|
PACK_FRESH_CONTRACT_MATCHES,
|
||||||
);
|
);
|
||||||
assert_eq!(it["id"], 100000001, "wire instance id");
|
assert_eq!(it["id"], 100000001, "wire instance id");
|
||||||
assert_eq!(it["resourceId"], 20801);
|
assert_eq!(it["resourceId"], 20801);
|
||||||
@@ -596,6 +691,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
&ent,
|
&ent,
|
||||||
legacy_discard_value(84),
|
legacy_discard_value(84),
|
||||||
|
PACK_FRESH_CONTRACT_MATCHES,
|
||||||
);
|
);
|
||||||
let b = shape_item(
|
let b = shape_item(
|
||||||
&item("oc-b", "fifa17_101490", 84, "ST"),
|
&item("oc-b", "fifa17_101490", 84, "ST"),
|
||||||
@@ -607,6 +703,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
&ent,
|
&ent,
|
||||||
legacy_discard_value(84),
|
legacy_discard_value(84),
|
||||||
|
PACK_FRESH_CONTRACT_MATCHES,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a["resourceId"], b["resourceId"],
|
a["resourceId"], b["resourceId"],
|
||||||
@@ -636,6 +733,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
&ent,
|
&ent,
|
||||||
legacy_discard_value(92),
|
legacy_discard_value(92),
|
||||||
|
PACK_FRESH_CONTRACT_MATCHES,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
it["resourceId"], 117617092,
|
it["resourceId"], 117617092,
|
||||||
@@ -650,6 +748,74 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `contract` USED to be a hardcoded `7`, so every card looked pack-fresh no
|
||||||
|
/// matter what Core had persisted — a contract consumable could be applied,
|
||||||
|
/// committed and then be invisible on the very screen that spends it. The
|
||||||
|
/// shaper must emit the number it was PASSED.
|
||||||
|
#[test]
|
||||||
|
fn contract_is_the_passed_value_not_a_constant() {
|
||||||
|
let ent = entities();
|
||||||
|
let id = Fifa17Identity {
|
||||||
|
item_id: 100000001,
|
||||||
|
asset_id: 20801,
|
||||||
|
resource_id: 20801,
|
||||||
|
rareflag: 1,
|
||||||
|
};
|
||||||
|
let base = item("oc1", "card_ch_1", 86, "CDM");
|
||||||
|
let it = shape_item(&base, id, &ent, legacy_discard_value(86), 22);
|
||||||
|
assert_eq!(it["contract"], 22, "the passed count, not 7");
|
||||||
|
// The whole 0..=99 range reaches the wire verbatim, including a spent
|
||||||
|
// card (0) and a capped one (99) — no clamping, no substitution.
|
||||||
|
for contract in [0, 1, 7, 22, 99] {
|
||||||
|
let it = shape_item(&base, id, &ent, legacy_discard_value(86), contract);
|
||||||
|
assert_eq!(it["contract"], contract);
|
||||||
|
}
|
||||||
|
// `fitness` is the same class of hardcode and deliberately out of scope
|
||||||
|
// here; asserting it keeps this test honest about what it proved.
|
||||||
|
assert_eq!(it["fitness"], 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core stores NULL for an instance it tracks no contract for, so the
|
||||||
|
/// FIFA-specific pack-fresh default is substituted by the CALLER — the same
|
||||||
|
/// `unwrap_or` both production call sites use.
|
||||||
|
#[test]
|
||||||
|
fn an_untracked_instance_falls_back_to_pack_fresh() {
|
||||||
|
let ent = entities();
|
||||||
|
let id = Fifa17Identity {
|
||||||
|
item_id: 100000001,
|
||||||
|
asset_id: 20801,
|
||||||
|
resource_id: 20801,
|
||||||
|
rareflag: 1,
|
||||||
|
};
|
||||||
|
let mut untracked = item("oc1", "card_ch_1", 86, "CDM");
|
||||||
|
untracked.contract_matches = None;
|
||||||
|
let it = shape_item(
|
||||||
|
&untracked,
|
||||||
|
id,
|
||||||
|
&ent,
|
||||||
|
legacy_discard_value(86),
|
||||||
|
untracked
|
||||||
|
.contract_matches
|
||||||
|
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||||
|
);
|
||||||
|
assert_eq!(it["contract"], PACK_FRESH_CONTRACT_MATCHES);
|
||||||
|
assert_eq!(it["contract"], 7, "the proven pack-fresh count");
|
||||||
|
|
||||||
|
// A tracked instance is NOT overwritten by the fallback.
|
||||||
|
let mut tracked = item("oc1", "card_ch_1", 86, "CDM");
|
||||||
|
tracked.contract_matches = Some(31);
|
||||||
|
let it = shape_item(
|
||||||
|
&tracked,
|
||||||
|
id,
|
||||||
|
&ent,
|
||||||
|
legacy_discard_value(86),
|
||||||
|
tracked
|
||||||
|
.contract_matches
|
||||||
|
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||||
|
);
|
||||||
|
assert_eq!(it["contract"], 31);
|
||||||
|
}
|
||||||
|
|
||||||
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
|
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
|
||||||
/// rating 85, amount 15. Its key set is the acceptance criterion.
|
/// rating 85, amount 15. Its key set is the acceptance criterion.
|
||||||
fn training_consumable() -> Fifa17ConsumableIdentity {
|
fn training_consumable() -> Fifa17ConsumableIdentity {
|
||||||
@@ -741,6 +907,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
&ent,
|
&ent,
|
||||||
legacy_discard_value(86),
|
legacy_discard_value(86),
|
||||||
|
PACK_FRESH_CONTRACT_MATCHES,
|
||||||
);
|
);
|
||||||
emitted.push(player["itemState"].as_str().unwrap().to_string());
|
emitted.push(player["itemState"].as_str().unwrap().to_string());
|
||||||
let staff = shape_staff_item(
|
let staff = shape_staff_item(
|
||||||
@@ -769,6 +936,8 @@ mod tests {
|
|||||||
card_asset_id: 35,
|
card_asset_id: 35,
|
||||||
subtype: 9,
|
subtype: 9,
|
||||||
team_id: 21,
|
team_id: 21,
|
||||||
|
category: 2,
|
||||||
|
year: 0,
|
||||||
};
|
};
|
||||||
for state in [
|
for state in [
|
||||||
item_state::FREE,
|
item_state::FREE,
|
||||||
@@ -806,6 +975,8 @@ mod tests {
|
|||||||
card_asset_id: 39,
|
card_asset_id: 39,
|
||||||
subtype,
|
subtype,
|
||||||
team_id: 21,
|
team_id: 21,
|
||||||
|
category: 2,
|
||||||
|
year: 0,
|
||||||
};
|
};
|
||||||
for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] {
|
for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] {
|
||||||
let it = shape_club_item(ident(subtype), item_state::FREE);
|
let it = shape_club_item(ident(subtype), item_state::FREE);
|
||||||
@@ -848,4 +1019,90 @@ mod tests {
|
|||||||
assert!(stadium.get(key).is_none(), "club item must not carry {key}");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod club_response;
|
|||||||
pub mod club_stats;
|
pub mod club_stats;
|
||||||
pub mod consumables;
|
pub mod consumables;
|
||||||
pub mod content_taxonomy;
|
pub mod content_taxonomy;
|
||||||
|
pub mod contract_cards;
|
||||||
pub mod discard;
|
pub mod discard;
|
||||||
pub mod economy;
|
pub mod economy;
|
||||||
pub mod economy_policy;
|
pub mod economy_policy;
|
||||||
@@ -26,3 +27,4 @@ pub mod squad_ext;
|
|||||||
pub mod squad_projection;
|
pub mod squad_projection;
|
||||||
pub mod store_catalog;
|
pub mod store_catalog;
|
||||||
pub mod store_session;
|
pub mod store_session;
|
||||||
|
pub mod training_cards;
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ pub const SEASON_ROUNDS: i64 = 10;
|
|||||||
/// resolves to a team the client can actually render. They are cycled rather
|
/// 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
|
/// than randomised so a season's schedule is stable across reloads — the client
|
||||||
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
|
/// 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];
|
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
|
||||||
|
|
||||||
/// One scheduled offline-season round.
|
/// One scheduled offline-season round.
|
||||||
@@ -90,9 +94,9 @@ pub struct SeasonUser {
|
|||||||
pub data: &'static str,
|
pub data: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn round(index: i64) -> SeasonMatch {
|
fn round(index: i64, opponents: &[i64]) -> SeasonMatch {
|
||||||
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
|
// Difficulty and reward multiplier are per-round bytes; a flat schedule
|
||||||
// is the honest default until the retail ladder is captured.
|
// is the honest default until the retail ladder is captured.
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -104,13 +108,35 @@ fn round(index: i64) -> SeasonMatch {
|
|||||||
|
|
||||||
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
||||||
/// text (see the module note on key order).
|
/// 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 {
|
let list = SeasonList {
|
||||||
seasons: vec![SeasonElement {
|
seasons: vec![SeasonElement {
|
||||||
kind: "OFFLINE",
|
kind: "OFFLINE",
|
||||||
id: season_id,
|
id: season_id,
|
||||||
division_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")
|
serde_json::to_string(&list).expect("season list serialises")
|
||||||
@@ -146,7 +172,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_emits_a_full_round_schedule() {
|
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];
|
let season = &body["seasons"][0];
|
||||||
assert_eq!(season["type"], "OFFLINE");
|
assert_eq!(season["type"], "OFFLINE");
|
||||||
assert_eq!(season["id"], 1);
|
assert_eq!(season["id"], 1);
|
||||||
@@ -161,7 +187,7 @@ mod tests {
|
|||||||
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_are_never_empty_and_every_round_has_a_team() {
|
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();
|
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||||
assert!(!matches.is_empty());
|
assert!(!matches.is_empty());
|
||||||
for (i, m) in matches.iter().enumerate() {
|
for (i, m) in matches.iter().enumerate() {
|
||||||
@@ -181,7 +207,7 @@ mod tests {
|
|||||||
/// order them alphabetically and break this.
|
/// order them alphabetically and break this.
|
||||||
#[test]
|
#[test]
|
||||||
fn type_is_serialised_before_division_id() {
|
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 type_at = text.find("\"type\"").expect("type key");
|
||||||
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
||||||
assert!(
|
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]
|
#[test]
|
||||||
fn user_state_carries_the_season_position() {
|
fn user_state_carries_the_season_position() {
|
||||||
let body = parsed(&season_user_body(1, 10, 3, 6));
|
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()))
|
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
|
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
/// (`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
|
/// `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.
|
/// 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 {
|
pub struct Fifa17SquadExtensionV1 {
|
||||||
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -35,10 +35,14 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
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::entities::ReverseEntityResolver;
|
||||||
use crate::fut::item::{
|
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::FIFA17_SQUAD_SLOTS;
|
||||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||||
|
|
||||||
@@ -166,7 +170,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
players.push(json!({
|
players.push(json!({
|
||||||
"index": index,
|
"index": index,
|
||||||
"itemData": shape_item(item, id, ent, ident.discard_value(item)),
|
"itemData": shape_item(
|
||||||
|
item,
|
||||||
|
id,
|
||||||
|
ent,
|
||||||
|
ident.discard_value(item),
|
||||||
|
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||||
|
),
|
||||||
"kitNumber": kit,
|
"kitNumber": kit,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -178,33 +188,53 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
|
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref and
|
||||||
// AND carrying its item, as `[{id, itemData, dream}]`.
|
// 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
|
// This is a wire-shape contract, recovered from the client rather than
|
||||||
// debugging round: the operator picked a manager in the hub, the save
|
// guessed, after two earlier shapes both failed:
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// The squad response is self-contained for players: `players[].itemData`
|
// `[{id, dream}]` — no merge key, so nothing resolves.
|
||||||
// carries the whole card rather than an id the client resolves out of band.
|
// `[{id, itemData, dream}]` — `itemData` is never read on this path.
|
||||||
// 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
|
// The squad parser FUN_18013d1f0 treats the two slots differently, and that
|
||||||
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
|
// is the whole point:
|
||||||
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
|
//
|
||||||
// `{id, itemData, dream}` here.
|
// 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
|
// An owned manager with no resolvable FIFA staff identity is omitted
|
||||||
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
||||||
// with a fabricated id.
|
// with a fabricated id.
|
||||||
let manager = match input.manager.as_ref().and_then(|m| ident.resolve_staff(m)) {
|
let manager = match input
|
||||||
Some(id) => json!([{
|
.manager
|
||||||
"id": id.item_id,
|
.as_ref()
|
||||||
"itemData": shape_staff_item(id, STAFF_CONTRACT),
|
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
|
||||||
"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!([]),
|
None => json!([]),
|
||||||
};
|
};
|
||||||
let squad = json!({
|
let squad = json!({
|
||||||
@@ -224,15 +254,76 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
|||||||
Ok(SquadProjection::Projected(squad))
|
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
|
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
|
||||||
/// the session-envelope fields the projector does not own (`personaId`, plus the
|
/// the session-envelope fields the projector does not own: `personaId`, the
|
||||||
/// observed constants `changed: 0`, `actives: []`).
|
/// observed constant `changed: 0`, and `actives` from [`squad_actives`].
|
||||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
|
pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> Value {
|
||||||
let mut obj = projected;
|
let mut obj = projected;
|
||||||
if let Value::Object(map) = &mut obj {
|
if let Value::Object(map) = &mut obj {
|
||||||
map.insert("personaId".into(), json!(persona_id));
|
map.insert("personaId".into(), json!(persona_id));
|
||||||
map.insert("changed".into(), json!(0));
|
map.insert("changed".into(), json!(0));
|
||||||
map.insert("actives".into(), json!([]));
|
map.insert("actives".into(), actives);
|
||||||
}
|
}
|
||||||
obj
|
obj
|
||||||
}
|
}
|
||||||
@@ -287,6 +378,9 @@ mod tests {
|
|||||||
league: "l".into(),
|
league: "l".into(),
|
||||||
club: "c".into(),
|
club: "c".into(),
|
||||||
attributes: [80, 80, 80, 80, 40, 80],
|
attributes: [80, 80, 80, 80, 40, 80],
|
||||||
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,18 +596,22 @@ mod tests {
|
|||||||
)]),
|
)]),
|
||||||
);
|
);
|
||||||
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||||
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
|
// A manager mid-way through its contracts: the projection must report
|
||||||
|
// Core's persisted count, not the pack-fresh constant, or the pre-match
|
||||||
|
// screen contradicts the club screen.
|
||||||
|
let mut manager = owned_item("oc-mgr", "fifa17_mgr");
|
||||||
|
manager.contract_matches = Some(12);
|
||||||
|
input.manager = Some(manager);
|
||||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||||
panic!("expected Projected");
|
panic!("expected Projected");
|
||||||
};
|
};
|
||||||
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
|
// The element IS the item: the squad parser's manager branch calls the
|
||||||
// pre-match squad with no manager even though the assignment had been
|
// item parser on the array element itself, with no `itemData` step, so
|
||||||
// saved, because nothing in the response described the card.
|
// 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!(
|
assert_eq!(
|
||||||
v["manager"],
|
v["manager"],
|
||||||
json!([{
|
json!([{
|
||||||
"id": 100000427,
|
|
||||||
"itemData": {
|
|
||||||
"id": 100000427,
|
"id": 100000427,
|
||||||
"resourceId": 1_000_509,
|
"resourceId": 1_000_509,
|
||||||
"cardsubtypeid": 4,
|
"cardsubtypeid": 4,
|
||||||
@@ -521,14 +619,24 @@ mod tests {
|
|||||||
"nation": 45,
|
"nation": 45,
|
||||||
"leagueId": 53,
|
"leagueId": 53,
|
||||||
"teamid": 241,
|
"teamid": 241,
|
||||||
"contract": STAFF_CONTRACT,
|
"contract": 12,
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": false,
|
"untradeable": false,
|
||||||
},
|
|
||||||
"dream": 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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//! FIFA 17 attribute training cards: which attribute a card trains, and by how
|
||||||
|
//! much.
|
||||||
|
//!
|
||||||
|
//! ## Where this comes from
|
||||||
|
//!
|
||||||
|
//! Two independent shipped sources, no invention:
|
||||||
|
//!
|
||||||
|
//! * **which attribute** — `cardsubtypeid`. `FUN_18013f4d0` derives a
|
||||||
|
//! consumable's whole presentation from that one field, and for the training
|
||||||
|
//! families it writes an attribute selector to `rec+0xbc` and the magnitude to
|
||||||
|
//! `rec+0xbf`. The selector per subtype is recorded in
|
||||||
|
//! `fifa17-recon/data/consumables.json` (`subtypes[].bc`, with the client's own
|
||||||
|
//! `FUT_UC_*` / `FUT_MC_*` string for each). STATIC_REVERSED.
|
||||||
|
//! * **how much** — `fcc_trainingcards.amount`, EA's shipped table. Every owned
|
||||||
|
//! consumable's wire `amount` matches that column 8/8. TABLE_PROVEN.
|
||||||
|
//!
|
||||||
|
//! ## Why the effect is ours to define at all
|
||||||
|
//!
|
||||||
|
//! No binary in the FIFA 17 install reads `fcc_trainingcards` at any casing, so
|
||||||
|
//! unlike quick-sell (`fcc_discardcoins`, which the client DOES read) there is no
|
||||||
|
//! client-side oracle for a consumable effect and never will be. The client ACKs
|
||||||
|
//! an apply on transport code alone and then re-reads state. Whatever the server
|
||||||
|
//! durably stores and re-serves IS what the player sees. That makes the
|
||||||
|
//! *magnitude* and the *target attribute* recoverable facts — the two above — and
|
||||||
|
//! everything about the effect's LIFECYCLE a server policy we must state
|
||||||
|
//! explicitly rather than pretend to have reversed. See
|
||||||
|
//! `TRAINING_MATCH_EXPIRY` below.
|
||||||
|
//!
|
||||||
|
//! ## Slot numbering
|
||||||
|
//!
|
||||||
|
//! The `attribute_index` this module produces is a slot in CORE's six-attribute
|
||||||
|
//! model (0 pace, 1 shooting, 2 passing, 3 dribbling, 4 defending, 5 physical),
|
||||||
|
//! not a FIFA attribute id. A goalkeeper's six attributes occupy those same six
|
||||||
|
//! slots on the wire — DIV/HAN/KIC/REF/SPD/POS in that order — which is why a GK
|
||||||
|
//! card and an outfield card can share one slot vocabulary.
|
||||||
|
|
||||||
|
/// Which class of player a training card may be applied to.
|
||||||
|
///
|
||||||
|
/// FIFA 17 authors the two families separately (`FUT_UC_*` for keepers,
|
||||||
|
/// `FUT_MC_*` for outfielders) and their slots mean different attributes, so
|
||||||
|
/// applying one to the wrong class would silently train the wrong stat.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TrainingClass {
|
||||||
|
Goalkeeper,
|
||||||
|
Outfield,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resolved training effect: one attribute slot (or all six), one magnitude,
|
||||||
|
/// one legal target class.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct TrainingEffect {
|
||||||
|
pub class: TrainingClass,
|
||||||
|
/// Slot in Core's six-attribute model, or `None` for the rare card that
|
||||||
|
/// boosts ALL SIX.
|
||||||
|
pub attribute_index: Option<i64>,
|
||||||
|
pub amount: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The largest magnitude EA authors for a SINGLE-attribute training card.
|
||||||
|
///
|
||||||
|
/// `fcc_trainingcards` authors exactly 5, 10 and 15 for all twelve
|
||||||
|
/// single-attribute families. Declared to Core on every apply so Core can refuse
|
||||||
|
/// a larger boost than any real card could grant, which is what keeps the closed
|
||||||
|
/// vocabulary from being a blank cheque.
|
||||||
|
pub const TRAINING_MAX_AMOUNT: i64 = 15;
|
||||||
|
|
||||||
|
/// The largest magnitude EA authors for the RARE all-six training card.
|
||||||
|
///
|
||||||
|
/// The two all-six subtypes author 3/6/10 only, so a +15 all-six card does not
|
||||||
|
/// exist and must not be describable to Core.
|
||||||
|
pub const TRAINING_ALL_MAX_AMOUNT: i64 = 10;
|
||||||
|
|
||||||
|
/// GK attribute training subtypes → Core attribute slot.
|
||||||
|
///
|
||||||
|
/// The client's own order is DIV, HAN, KIC, REF, SPD, POS, and `bc` follows it;
|
||||||
|
/// note that the subtype ids do NOT (54 is SPEED at slot 4, 56 is REFLEXES at
|
||||||
|
/// slot 3). Reading these off in subtype order instead of `bc` order is exactly
|
||||||
|
/// the mistake this table exists to prevent.
|
||||||
|
const GK_TRAINING: &[(i64, i64)] = &[
|
||||||
|
(51, 0), // FUT_UC_DIVING
|
||||||
|
(52, 1), // FUT_UC_HANDLING
|
||||||
|
(53, 2), // FUT_UC_KICKING
|
||||||
|
(56, 3), // FUT_UC_REFLEXES
|
||||||
|
(54, 4), // FUT_UC_SPEED
|
||||||
|
(55, 5), // FUT_UC_POSITIONING
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Outfield attribute training subtypes → Core attribute slot.
|
||||||
|
///
|
||||||
|
/// Same trap as the keepers: 65 is HEADING at slot 5 (Core's `physical`) and 66
|
||||||
|
/// is DEFENDING at slot 4.
|
||||||
|
const OUTFIELD_TRAINING: &[(i64, i64)] = &[
|
||||||
|
(61, 0), // FUT_MC_PACE
|
||||||
|
(62, 1), // FUT_MC_SHOOTING
|
||||||
|
(63, 2), // FUT_MC_PASSING
|
||||||
|
(64, 3), // FUT_MC_DRIBBLING
|
||||||
|
(66, 4), // FUT_MC_DEFENDING
|
||||||
|
(65, 5), // FUT_MC_HEADING -> Core's `physical` slot
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The RARE training cards, which boost ALL SIX attributes at once.
|
||||||
|
///
|
||||||
|
/// These were previously mistaken for "squad fitness" on the strength of the
|
||||||
|
/// reversed label `FUT_FITNESS_UC` / `FUT_FITNESS_MC`. That label is
|
||||||
|
/// tool-authored (`build_consumables.py` names the 7th element of its attribute
|
||||||
|
/// array) and has no documented provenance; four independent facts say all-six:
|
||||||
|
///
|
||||||
|
/// * each family holds exactly 21 rows = 7 card types x 3 levels, and the
|
||||||
|
/// published FIFA 17 card list is 6 single attributes + 1 "ALL";
|
||||||
|
/// * these six rows are the ONLY ones in the table with `weightrare = 2`; all 36
|
||||||
|
/// single-attribute rows are `weightrare = 0`. The published list marks the
|
||||||
|
/// ALL card RARE and every single-attribute card non-rare;
|
||||||
|
/// * their amounts are exactly 3/6/10, matching the published ALL card's
|
||||||
|
/// +3 bronze / +6 silver / +10 gold, while single attributes are 5/10/15;
|
||||||
|
/// * `bc = 6` is one past the six real slots (0..5) -- an "all" sentinel -- and
|
||||||
|
/// `c0 = 0` reads as "not single-ATTRIBUTE", not "not single-target".
|
||||||
|
///
|
||||||
|
/// The genuine squad-fitness card is subtype 220 in a DIFFERENT table
|
||||||
|
/// (`fcc_healingcards`, amounts 10/20/30), and player fitness is 219 — that
|
||||||
|
/// family is separately identified and remains unsupported.
|
||||||
|
const ALL_ATTRIBUTE_TRAINING: &[(i64, TrainingClass)] = &[
|
||||||
|
(57, TrainingClass::Goalkeeper),
|
||||||
|
(67, TrainingClass::Outfield),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Resolve a consumable into a training effect, or `None` if it is not an
|
||||||
|
/// attribute training card.
|
||||||
|
///
|
||||||
|
/// `amount` is the wire/catalog magnitude for the card. It is required: the
|
||||||
|
/// client parser initialises its amount temp to `-1` and reads it signed, so a
|
||||||
|
/// missing magnitude is not "zero", it is a card that would draw and grant
|
||||||
|
/// nonsense. Absent or out-of-range, this refuses.
|
||||||
|
pub fn training_effect(subtype: i64, amount: Option<i64>) -> Option<TrainingEffect> {
|
||||||
|
let (class, attribute_index, ceiling) = GK_TRAINING
|
||||||
|
.iter()
|
||||||
|
.find(|&&(s, _)| s == subtype)
|
||||||
|
.map(|&(_, slot)| (TrainingClass::Goalkeeper, Some(slot), TRAINING_MAX_AMOUNT))
|
||||||
|
.or_else(|| {
|
||||||
|
OUTFIELD_TRAINING
|
||||||
|
.iter()
|
||||||
|
.find(|&&(s, _)| s == subtype)
|
||||||
|
.map(|&(_, slot)| (TrainingClass::Outfield, Some(slot), TRAINING_MAX_AMOUNT))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
ALL_ATTRIBUTE_TRAINING
|
||||||
|
.iter()
|
||||||
|
.find(|&&(s, _)| s == subtype)
|
||||||
|
.map(|&(_, class)| (class, None, TRAINING_ALL_MAX_AMOUNT))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let amount = amount?;
|
||||||
|
if !(1..=ceiling).contains(&amount) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(TrainingEffect {
|
||||||
|
class,
|
||||||
|
attribute_index,
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ceiling Core must be told for a resolved effect: the two families author
|
||||||
|
/// different maxima, and sending the wrong one either lets an impossible boost
|
||||||
|
/// through or refuses a legitimate card.
|
||||||
|
pub fn ceiling_for(effect: &TrainingEffect) -> i64 {
|
||||||
|
match effect.attribute_index {
|
||||||
|
Some(_) => TRAINING_MAX_AMOUNT,
|
||||||
|
None => TRAINING_ALL_MAX_AMOUNT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a target playing in `position` may receive `class` training.
|
||||||
|
///
|
||||||
|
/// The client's own `pos` vocabulary numbers GK 0 and gives every outfield role
|
||||||
|
/// its own id, so the distinction is exactly "is the target a keeper".
|
||||||
|
pub fn class_accepts_position(class: TrainingClass, position: &str) -> bool {
|
||||||
|
let is_gk = position.eq_ignore_ascii_case("GK");
|
||||||
|
match class {
|
||||||
|
TrainingClass::Goalkeeper => is_gk,
|
||||||
|
TrainingClass::Outfield => !is_gk,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What clears an applied training effect, if anything.
|
||||||
|
///
|
||||||
|
/// UNKNOWN, and deliberately recorded as a constant so it cannot be quietly
|
||||||
|
/// assumed. FIFA 17 ships no table describing a training lifetime, the client
|
||||||
|
/// holds no consumable-effect logic to reverse one from, and "training is
|
||||||
|
/// temporary in FUT" is a recollection about other titles, not evidence about
|
||||||
|
/// this one. Until an experiment settles it, an applied effect PERSISTS, and no
|
||||||
|
/// code decrements or expires it.
|
||||||
|
pub const TRAINING_MATCH_EXPIRY: &str = "UNKNOWN";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The slot must come from `bc`, never from the subtype's ordinal position.
|
||||||
|
/// 54/56 (keeper) and 65/66 (outfield) are the pairs that catch a
|
||||||
|
/// sequential misreading.
|
||||||
|
#[test]
|
||||||
|
fn out_of_order_subtypes_map_to_their_reversed_slots() {
|
||||||
|
assert_eq!(
|
||||||
|
training_effect(54, Some(10)).unwrap().attribute_index,
|
||||||
|
Some(4)
|
||||||
|
); // SPEED
|
||||||
|
assert_eq!(
|
||||||
|
training_effect(56, Some(10)).unwrap().attribute_index,
|
||||||
|
Some(3)
|
||||||
|
); // REFLEXES
|
||||||
|
assert_eq!(
|
||||||
|
training_effect(65, Some(10)).unwrap().attribute_index,
|
||||||
|
Some(5)
|
||||||
|
); // HEADING
|
||||||
|
assert_eq!(
|
||||||
|
training_effect(66, Some(10)).unwrap().attribute_index,
|
||||||
|
Some(4)
|
||||||
|
); // DEFENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every attribute training subtype resolves, and the two families cover
|
||||||
|
/// Core's six slots exactly once each.
|
||||||
|
#[test]
|
||||||
|
fn both_families_cover_all_six_slots_exactly_once() {
|
||||||
|
for (family, subtypes) in [
|
||||||
|
(TrainingClass::Goalkeeper, GK_TRAINING),
|
||||||
|
(TrainingClass::Outfield, OUTFIELD_TRAINING),
|
||||||
|
] {
|
||||||
|
let mut slots: Vec<i64> = subtypes
|
||||||
|
.iter()
|
||||||
|
.map(|&(s, _)| {
|
||||||
|
let e = training_effect(s, Some(5)).expect("subtype resolves");
|
||||||
|
assert_eq!(e.class, family);
|
||||||
|
e.attribute_index
|
||||||
|
.expect("single-attribute card names a slot")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
slots.sort_unstable();
|
||||||
|
assert_eq!(slots, vec![0, 1, 2, 3, 4, 5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rare pair boost ALL SIX attributes, so they resolve with NO slot.
|
||||||
|
/// Reading them as single-attribute would apply their magnitude to whatever
|
||||||
|
/// slot 0 happens to be and drop the other five.
|
||||||
|
#[test]
|
||||||
|
fn the_rare_cards_boost_all_six_attributes() {
|
||||||
|
for (subtype, class) in [
|
||||||
|
(57, TrainingClass::Goalkeeper),
|
||||||
|
(67, TrainingClass::Outfield),
|
||||||
|
] {
|
||||||
|
for amount in [3, 6, 10] {
|
||||||
|
let e = training_effect(subtype, Some(amount)).expect("rare card resolves");
|
||||||
|
assert_eq!(e.attribute_index, None, "subtype {subtype} must be all-six");
|
||||||
|
assert_eq!(e.class, class);
|
||||||
|
assert_eq!(e.amount, amount);
|
||||||
|
assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The all-six card authors 3/6/10 only. A +15 all-six card does not exist,
|
||||||
|
/// and letting one through would grant 90 attribute points from a card that
|
||||||
|
/// grants at most 60.
|
||||||
|
#[test]
|
||||||
|
fn the_rare_card_cannot_carry_a_single_attribute_magnitude() {
|
||||||
|
assert_eq!(training_effect(57, Some(15)), None);
|
||||||
|
assert_eq!(training_effect(67, Some(15)), None);
|
||||||
|
// ...while a single-attribute card still may.
|
||||||
|
assert_eq!(training_effect(51, Some(15)).unwrap().amount, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A missing magnitude is a refusal, not a zero: the client reads the byte
|
||||||
|
/// signed from a -1 initial value.
|
||||||
|
#[test]
|
||||||
|
fn a_missing_or_impossible_amount_refuses() {
|
||||||
|
assert_eq!(training_effect(52, None), None);
|
||||||
|
assert_eq!(training_effect(52, Some(0)), None);
|
||||||
|
assert_eq!(training_effect(52, Some(-1)), None);
|
||||||
|
assert_eq!(training_effect(52, Some(TRAINING_MAX_AMOUNT + 1)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the shipped magnitudes are accepted, and all three are.
|
||||||
|
#[test]
|
||||||
|
fn the_three_authored_magnitudes_all_resolve() {
|
||||||
|
for a in [5, 10, 15] {
|
||||||
|
assert_eq!(training_effect(61, Some(a)).unwrap().amount, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Family/target gating is the whole reason `class` exists.
|
||||||
|
#[test]
|
||||||
|
fn each_family_accepts_only_its_own_target_class() {
|
||||||
|
assert!(class_accepts_position(TrainingClass::Goalkeeper, "GK"));
|
||||||
|
assert!(!class_accepts_position(TrainingClass::Goalkeeper, "ST"));
|
||||||
|
assert!(class_accepts_position(TrainingClass::Outfield, "ST"));
|
||||||
|
assert!(!class_accepts_position(TrainingClass::Outfield, "GK"));
|
||||||
|
// The wire's casing is not guaranteed to be ours.
|
||||||
|
assert!(class_accepts_position(TrainingClass::Goalkeeper, "gk"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-training consumable must never resolve here — contracts (201/202),
|
||||||
|
/// healing (211-218), fitness (219/220), position (91-110) and play styles
|
||||||
|
/// (250-273) all share the consumable space.
|
||||||
|
#[test]
|
||||||
|
fn other_consumable_families_do_not_resolve_as_training() {
|
||||||
|
for s in [201, 202, 211, 218, 219, 220, 91, 110, 250, 271, 300] {
|
||||||
|
assert_eq!(training_effect(s, Some(5)), None, "subtype {s} resolved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,16 +24,18 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
|
||||||
use openfut_adapter_fifa17::fut::item::{
|
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::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
|
||||||
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
|
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
|
||||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
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,
|
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_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
|
||||||
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
|
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
|
||||||
@@ -114,6 +116,12 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
|
|||||||
league: String::new(),
|
league: String::new(),
|
||||||
club: String::new(),
|
club: String::new(),
|
||||||
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
|
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
|
||||||
|
// The captured wire carries the club's real per-instance
|
||||||
|
// contract count, so the round trip proves the PERSISTED number
|
||||||
|
// reaches the wire rather than a constant.
|
||||||
|
contract_matches: it["contract"].as_i64(),
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ident.insert(
|
ident.insert(
|
||||||
@@ -335,6 +343,15 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
|||||||
league: String::new(),
|
league: String::new(),
|
||||||
club: String::new(),
|
club: String::new(),
|
||||||
attributes: [0; 6],
|
attributes: [0; 6],
|
||||||
|
// The captured `manager` ref is the bare `{id, dream}` form, so the wire
|
||||||
|
// carries no staff contract to mirror: this instance is untracked and
|
||||||
|
// must fall back to the pack-fresh default.
|
||||||
|
contract_matches: None,
|
||||||
|
// Core's authored staff `value`; the squad projection never reads it (the
|
||||||
|
// client re-rates a manager from its own table), so the round trip is
|
||||||
|
// unaffected either way.
|
||||||
|
source_rating: Some(88),
|
||||||
|
core_content_kind: Some("manager".to_string()),
|
||||||
};
|
};
|
||||||
let kicktakers: Vec<KicktakerRef> =
|
let kicktakers: Vec<KicktakerRef> =
|
||||||
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
||||||
@@ -393,10 +410,13 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
|||||||
}
|
}
|
||||||
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
||||||
assert_eq!(projected["custom"], oracle["custom"]);
|
assert_eq!(projected["custom"], oracle["custom"]);
|
||||||
// The manager REF round-trips; the item now rides with it. The capture this
|
// The manager REF round-trips; the item now rides AT ELEMENT LEVEL. The
|
||||||
// oracle came from carried a bare `{id, dream}`, but its manager was the
|
// capture this oracle came from carried a bare `{id, dream}`, but its
|
||||||
// dangling one every retail capture has, so it never showed that a populated
|
// manager was the dangling one every retail capture has, so it never showed
|
||||||
// ref renders on its own — and in practice it did not.
|
// 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!(
|
assert_eq!(
|
||||||
projected["manager"][0]["id"], oracle["manager"][0]["id"],
|
projected["manager"][0]["id"], oracle["manager"][0]["id"],
|
||||||
"the manager wire ref itself must still round-trip"
|
"the manager wire ref itself must still round-trip"
|
||||||
@@ -405,11 +425,17 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
|||||||
projected["manager"][0]["dream"],
|
projected["manager"][0]["dream"],
|
||||||
oracle["manager"][0]["dream"]
|
oracle["manager"][0]["dream"]
|
||||||
);
|
);
|
||||||
let mgr_item = &projected["manager"][0]["itemData"];
|
let mgr_item = &projected["manager"][0];
|
||||||
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
|
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["cardsubtypeid"], 4);
|
||||||
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
||||||
assert_eq!(mgr_item["contract"], STAFF_CONTRACT);
|
assert_eq!(
|
||||||
|
mgr_item["contract"], STAFF_CONTRACT,
|
||||||
|
"this manager instance is untracked, so the pack-fresh fallback shows"
|
||||||
|
);
|
||||||
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
|
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
|
||||||
assert_eq!(projected["squadType"], oracle["squadType"]);
|
assert_eq!(projected["squadType"], oracle["squadType"]);
|
||||||
assert_eq!(projected["chemistry"], oracle["chemistry"]);
|
assert_eq!(projected["chemistry"], oracle["chemistry"]);
|
||||||
@@ -485,11 +511,17 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
|||||||
&ident,
|
&ident,
|
||||||
);
|
);
|
||||||
|
|
||||||
// userMassInfo.squad = the projected object + session envelope.
|
// userMassInfo.squad = the projected object + session envelope. `actives` is
|
||||||
let ummi = user_mass_info_squad(projected.clone(), 33068179);
|
// 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["personaId"], 33068179);
|
||||||
assert_eq!(ummi["changed"], 0);
|
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["players"], projected["players"], "same projected body");
|
||||||
assert_eq!(ummi["formation"], projected["formation"]);
|
assert_eq!(ummi["formation"], projected["formation"]);
|
||||||
|
|
||||||
@@ -512,3 +544,153 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
|||||||
// The summary carries only those six keys — no divergent squad shape.
|
// The summary carries only those six keys — no divergent squad shape.
|
||||||
assert_eq!(entry.as_object().unwrap().len(), 6);
|
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: 233df1d99d...20e281e0cf
@@ -1317,6 +1317,15 @@ pub fn emit_content(
|
|||||||
.collect();
|
.collect();
|
||||||
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
|
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
|
||||||
// role name; Core stores them like any other definition (no FIFA concept).
|
// role name; Core stores them like any other definition (no FIFA concept).
|
||||||
|
//
|
||||||
|
// `overall` STAYS 0 while `source_rating` carries EA's authored value, and
|
||||||
|
// both are correct at once because they feed different consumers: `overall`
|
||||||
|
// is what Core prices and projects from (a non-zero one would silently
|
||||||
|
// re-price every staff quick sell), while `source_rating` is the authored
|
||||||
|
// number the game's own tier rules read (bronze <65 / silver 65..=74 /
|
||||||
|
// gold >=75) — the number a manager-contract grant needs. `d.rating` is the
|
||||||
|
// SAME value written to the host catalog below as `"rating": d.rating`, so
|
||||||
|
// the content pack and the catalog can never disagree about a card's tier.
|
||||||
for d in &report.non_player.supported {
|
for d in &report.non_player.supported {
|
||||||
defs.push(serde_json::json!({
|
defs.push(serde_json::json!({
|
||||||
"id": d.card_id,
|
"id": d.card_id,
|
||||||
@@ -1334,6 +1343,7 @@ pub fn emit_content(
|
|||||||
"physical": 0,
|
"physical": 0,
|
||||||
"rarity": "bronze",
|
"rarity": "bronze",
|
||||||
"image_path": serde_json::Value::Null,
|
"image_path": serde_json::Value::Null,
|
||||||
|
"source_rating": d.rating,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let content_pack = content_dir.join("fifa17-production-cards.json");
|
let content_pack = content_dir.join("fifa17-production-cards.json");
|
||||||
|
|||||||
@@ -946,6 +946,17 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
|||||||
assert_eq!(cons["nation"], "");
|
assert_eq!(cons["nation"], "");
|
||||||
assert_eq!(cons["rarity"], "bronze");
|
assert_eq!(cons["rarity"], "bronze");
|
||||||
assert!(cons["image_path"].is_null());
|
assert!(cons["image_path"].is_null());
|
||||||
|
// `source_rating` mirrors the wire verbatim: this consumable really sends
|
||||||
|
// `"rating":0`, so 0 is the authored value, not a substituted default.
|
||||||
|
assert_eq!(cons["source_rating"], 0, "{cons}");
|
||||||
|
// This fixture's `entities()` carries no staff table, so the coach's value is
|
||||||
|
// UNKNOWN — it must stay `null`, never a fabricated 0 a tier rule reads as
|
||||||
|
// bronze.
|
||||||
|
let coach = arr.iter().find(|c| c["id"] == "fifa17_3000083").unwrap();
|
||||||
|
assert!(
|
||||||
|
coach["source_rating"].is_null(),
|
||||||
|
"an unknown authored value must stay null: {coach}"
|
||||||
|
);
|
||||||
|
|
||||||
// Catalog: kind+subtype on player AND non-player; staff asset falls back to
|
// Catalog: kind+subtype on player AND non-player; staff asset falls back to
|
||||||
// resourceId; and the emitted catalog LOADS in the adapter with kind_of.
|
// resourceId; and the emitted catalog LOADS in the adapter with kind_of.
|
||||||
@@ -1141,3 +1152,55 @@ fn enrich_staff_fills_staff_and_leaves_everything_else_alone() {
|
|||||||
"the wire rating is left untouched"
|
"the wire rating is left untouched"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A MANAGER CONTRACT grant needs the target's TIER, and the only authoritative
|
||||||
|
/// source is EA's authored `value` from the staff family table. Emit must carry
|
||||||
|
/// it into Core's content pack as `source_rating` — the SAME number the host
|
||||||
|
/// catalog carries as `rating`, so the two artifacts can never disagree — while
|
||||||
|
/// `overall` stays 0, because `overall` is what Core prices and projects from.
|
||||||
|
///
|
||||||
|
/// Before this, a non-player reached Core with `overall: 0` and nothing else, so
|
||||||
|
/// `/collection` reported `effective_overall: 0` and a gold (76) manager graded
|
||||||
|
/// bronze.
|
||||||
|
#[test]
|
||||||
|
fn emit_content_carries_ea_authored_value_as_source_rating() {
|
||||||
|
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables");
|
||||||
|
let ent = Entities::from_tables_dir(dir).expect("committed tables load");
|
||||||
|
|
||||||
|
let items = vec![
|
||||||
|
staff(100000427, 1000001, 4), // manager — managercards.value = 76 (gold)
|
||||||
|
staff(100000280, 9000081, 6), // GK coach — gkcoachcards.value = 66 (silver)
|
||||||
|
];
|
||||||
|
let rep = analyze(&profile(&items, "[]", 100000500), &roster(), &ent, &none());
|
||||||
|
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||||
|
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
let sum = emit_content(&rep, out.path(), "fp").unwrap();
|
||||||
|
let pack: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
|
||||||
|
let cat: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
|
||||||
|
|
||||||
|
for (card_id, value) in [("fifa17_1000001", 76), ("fifa17_9000081", 66)] {
|
||||||
|
let def = pack
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["id"] == card_id)
|
||||||
|
.unwrap_or_else(|| panic!("{card_id} must be in the content pack"));
|
||||||
|
assert_eq!(
|
||||||
|
def["source_rating"],
|
||||||
|
serde_json::json!(value),
|
||||||
|
"EA's authored value must reach Core: {def}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
def["overall"],
|
||||||
|
serde_json::json!(0),
|
||||||
|
"overall stays 0 for a non-player: it feeds pricing and projection"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
def["source_rating"], cat["cards"][card_id]["rating"],
|
||||||
|
"one source, two artifacts — they must never disagree on a tier"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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;
|
/// Disabled by default. Non-off values require three explicit staging guards;
|
||||||
/// see [`parse_sbc_post_commit_fault`].
|
/// see [`parse_sbc_post_commit_fault`].
|
||||||
pub sbc_post_commit_fault: SbcPostCommitFault,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -91,6 +115,17 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
|||||||
Ok(val)
|
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(
|
fn parse_sbc_post_commit_fault(
|
||||||
raw: Option<&str>,
|
raw: Option<&str>,
|
||||||
environment: Option<&str>,
|
environment: Option<&str>,
|
||||||
@@ -177,6 +212,9 @@ impl HostConfig {
|
|||||||
clientdata_path,
|
clientdata_path,
|
||||||
account_path,
|
account_path,
|
||||||
sbc_post_commit_fault,
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn sbc_post_commit_faults_require_all_staging_guards() {
|
fn sbc_post_commit_faults_require_all_staging_guards() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use rand::Rng;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use openfut_adapter_fifa17::fut::club_response::shape_club_response;
|
use openfut_adapter_fifa17::fut::club_response::shape_club_response;
|
||||||
|
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||||
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
|
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
||||||
@@ -106,6 +107,13 @@ fn core_owned(m: &Minted) -> CoreOwnedItem {
|
|||||||
league: m.card.league.clone(),
|
league: m.card.league.clone(),
|
||||||
club: m.card.club.clone(),
|
club: m.card.club.clone(),
|
||||||
attributes: m.card.attributes,
|
attributes: m.card.attributes,
|
||||||
|
// Freshly minted by a pack/Store open, so Core tracks no contract for it
|
||||||
|
// yet: the shaper substitutes the pack-fresh default.
|
||||||
|
contract_matches: None,
|
||||||
|
// A pack mints PLAYER cards, whose rating IS `overall`, so there is no
|
||||||
|
// separate authored definition rating to carry.
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +175,10 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
|
|||||||
id,
|
id,
|
||||||
deps.entities,
|
deps.entities,
|
||||||
deps.assets.discard_value(&item),
|
deps.assets.discard_value(&item),
|
||||||
|
// A pack-pulled card is by definition pack-fresh, so it carries
|
||||||
|
// the default rather than a persisted count: Core has not yet
|
||||||
|
// stored this instance, let alone applied a contract to it.
|
||||||
|
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -464,8 +476,8 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
|
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
|
||||||
EconomySaleReceipt,
|
EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Recording economy double ────────────────────────────────────────────
|
// ── Recording economy double ────────────────────────────────────────────
|
||||||
@@ -608,6 +620,13 @@ mod tests {
|
|||||||
// Match completion is not exercised by the Store/quick-sell paths.
|
// Match completion is not exercised by the Store/quick-sell paths.
|
||||||
Err(CoreError::Status(501))
|
Err(CoreError::Status(501))
|
||||||
}
|
}
|
||||||
|
fn apply_consumable(
|
||||||
|
&self,
|
||||||
|
_req: &ConsumableApplyRequest<'_>,
|
||||||
|
) -> Result<ConsumableApplyOutcome, CoreError> {
|
||||||
|
// Consumable apply is not exercised by the Store/quick-sell paths.
|
||||||
|
Err(CoreError::Status(501))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
||||||
@@ -986,6 +1005,9 @@ mod tests {
|
|||||||
league: "Premier League".into(),
|
league: "Premier League".into(),
|
||||||
club: "Arsenal".into(),
|
club: "Arsenal".into(),
|
||||||
attributes: [rating; 6],
|
attributes: [rating; 6],
|
||||||
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1658
-64
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||||
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
||||||
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
|
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
|
||||||
use openfut_adapter_fifa17::fut::item_state;
|
use openfut_adapter_fifa17::fut::item_state;
|
||||||
@@ -284,7 +285,19 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
|
|||||||
let identity = resolver.resolve(&owned);
|
let identity = resolver.resolve(&owned);
|
||||||
let resource_id = identity.map(|id| id.resource_id as i64);
|
let resource_id = identity.map(|id| id.resource_id as i64);
|
||||||
let item_json = identity
|
let item_json = identity
|
||||||
.map(|id| shape_item(&owned, id, ent, resolver.discard_value(&owned)))
|
.map(|id| {
|
||||||
|
shape_item(
|
||||||
|
&owned,
|
||||||
|
id,
|
||||||
|
ent,
|
||||||
|
resolver.discard_value(&owned),
|
||||||
|
// A listing snapshot must show the contract the seller's card
|
||||||
|
// actually holds, so a part-used card cannot render as fresh.
|
||||||
|
owned
|
||||||
|
.contract_matches
|
||||||
|
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||||
|
)
|
||||||
|
})
|
||||||
.and_then(|card| serde_json::to_string(&card).ok());
|
.and_then(|card| serde_json::to_string(&card).ok());
|
||||||
Some(ResolvedListing {
|
Some(ResolvedListing {
|
||||||
item_id,
|
item_id,
|
||||||
@@ -806,8 +819,8 @@ pub async fn handle_move_items(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
|
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
|
||||||
EconomyPurchase, EconomySale, EconomySaleReceipt,
|
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||||
@@ -968,6 +981,13 @@ mod tests {
|
|||||||
// Match completion is not exercised through the market double.
|
// Match completion is not exercised through the market double.
|
||||||
Err(CoreError::Status(501))
|
Err(CoreError::Status(501))
|
||||||
}
|
}
|
||||||
|
fn apply_consumable(
|
||||||
|
&self,
|
||||||
|
_req: &ConsumableApplyRequest<'_>,
|
||||||
|
) -> Result<ConsumableApplyOutcome, CoreError> {
|
||||||
|
// Consumable apply is not exercised through the market double.
|
||||||
|
Err(CoreError::Status(501))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- SquadWireResolver double -----------------------------------------
|
// ---- SquadWireResolver double -----------------------------------------
|
||||||
@@ -1019,6 +1039,9 @@ mod tests {
|
|||||||
league: String::new(),
|
league: String::new(),
|
||||||
club: String::new(),
|
club: String::new(),
|
||||||
attributes: [80, 80, 80, 80, 80, 80],
|
attributes: [80, 80, 80, 80, 80, 80],
|
||||||
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ use openfut_utas_host::async_bridge::AsyncBridge;
|
|||||||
use openfut_utas_host::market_store::MarketStore;
|
use openfut_utas_host::market_store::MarketStore;
|
||||||
use openfut_utas_host::pile_store::PileStore;
|
use openfut_utas_host::pile_store::PileStore;
|
||||||
use openfut_utas_host::{
|
use openfut_utas_host::{
|
||||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
|
build_content_pool, ConsumableApplyOutcome, ConsumableApplyRequest, CoreAccess, CoreEconomy,
|
||||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
CoreError, CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
|
||||||
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
|
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
|
||||||
|
HttpCoreClient, PassClient, Server, WireResponse,
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -146,6 +147,15 @@ impl CoreEconomy for FaultEconomy {
|
|||||||
}
|
}
|
||||||
self.inner.complete_match(m)
|
self.inner.complete_match(m)
|
||||||
}
|
}
|
||||||
|
fn apply_consumable(
|
||||||
|
&self,
|
||||||
|
req: &ConsumableApplyRequest<'_>,
|
||||||
|
) -> Result<ConsumableApplyOutcome, CoreError> {
|
||||||
|
if self.trip("apply_consumable") {
|
||||||
|
return Err(Self::injected());
|
||||||
|
}
|
||||||
|
self.inner.apply_consumable(req)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
||||||
|
|||||||
@@ -902,6 +902,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned(),
|
.into_owned(),
|
||||||
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
|
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::{
|
use openfut_utas_host::{
|
||||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreRolePatchRequest,
|
||||||
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route,
|
||||||
|
Server, SquadDeps,
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -51,6 +52,10 @@ struct FakeCore {
|
|||||||
/// full squad replacement writes it (or clears it with `None`).
|
/// full squad replacement writes it (or clears it with `None`).
|
||||||
manager: Mutex<Option<String>>,
|
manager: Mutex<Option<String>>,
|
||||||
replaced: Mutex<Vec<StoredReplace>>,
|
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,
|
panic_if_called: bool,
|
||||||
return_err: bool,
|
return_err: bool,
|
||||||
}
|
}
|
||||||
@@ -95,6 +100,9 @@ impl FakeCore {
|
|||||||
fn last(&self) -> Vec<(String, String)> {
|
fn last(&self) -> Vec<(String, String)> {
|
||||||
self.last_params.lock().clone()
|
self.last_params.lock().clone()
|
||||||
}
|
}
|
||||||
|
fn role_patches(&self) -> Vec<(Option<String>, String)> {
|
||||||
|
self.role_patches.lock().clone()
|
||||||
|
}
|
||||||
fn manager(&self) -> Option<String> {
|
fn manager(&self) -> Option<String> {
|
||||||
self.manager.lock().clone()
|
self.manager.lock().clone()
|
||||||
}
|
}
|
||||||
@@ -132,6 +140,45 @@ impl CoreAccess for FakeCore {
|
|||||||
self.squad.lock().clone().ok_or(CoreError::Status(404))
|
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> {
|
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
|
||||||
assert!(
|
assert!(
|
||||||
!self.panic_if_called,
|
!self.panic_if_called,
|
||||||
@@ -198,11 +245,11 @@ impl CoreAccess for FakeCore {
|
|||||||
Ok(self.manager.lock().clone())
|
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 {
|
if self.return_err {
|
||||||
return Err(CoreError::Status(500));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +280,9 @@ fn item(
|
|||||||
league: league.into(),
|
league: league.into(),
|
||||||
club: club.into(),
|
club: club.into(),
|
||||||
attributes: [90, 88, 70, 85, 40, 78],
|
attributes: [90, 88, 70, 85, 40, 78],
|
||||||
|
contract_matches: None,
|
||||||
|
source_rating: None,
|
||||||
|
core_content_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1076,8 +1126,41 @@ fn classify_squad_and_usermassinfo_routes() {
|
|||||||
classify("GET", "/ut/game/fifa17/userMassInfo"),
|
classify("GET", "/ut/game/fifa17/userMassInfo"),
|
||||||
Route::UserMassInfo
|
Route::UserMassInfo
|
||||||
);
|
);
|
||||||
// GET /squad/active and every numeric GET are the one Core-backed current
|
// The retail client sends the lowercase tail too, immediately before the
|
||||||
// squad, matching the oracle's single-squad response regardless of URL id.
|
// 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!(
|
assert_eq!(
|
||||||
classify("GET", "/ut/game/fifa17/squad/active"),
|
classify("GET", "/ut/game/fifa17/squad/active"),
|
||||||
Route::SquadActive
|
Route::SquadActive
|
||||||
@@ -1147,6 +1230,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
"f442",
|
"f442",
|
||||||
@@ -1176,11 +1260,16 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
|||||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
/// The REAL captured partial body must succeed and take the PATCH path, not the
|
||||||
/// echo: a save assigns the owned instance behind the ref, and a later save
|
/// replacement path.
|
||||||
/// without a manager CLEARS it (a full replacement replaces the manager too).
|
///
|
||||||
|
/// 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]
|
#[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 items = vec![gk(), st()];
|
||||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||||
let core = FakeCore::new(items.clone(), 2);
|
let core = FakeCore::new(items.clone(), 2);
|
||||||
@@ -1189,6 +1278,144 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
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(
|
let with_manager = put_body(
|
||||||
@@ -1206,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"
|
"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 without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||||
let (resp, log) = handle_put_squad(&without_manager, &deps);
|
let (resp, log) = handle_put_squad(&without_manager, &deps);
|
||||||
assert_eq!(resp.status, 200, "{log:?}");
|
assert_eq!(resp.status, 200, "{log:?}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
core.manager(),
|
core.manager().as_deref(),
|
||||||
None,
|
Some("oc-b"),
|
||||||
"a full replacement without a manager clears the assignment"
|
"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
|
/// The REAL client always sends a manager ref, and on a real profile it does not
|
||||||
@@ -1231,6 +1462,7 @@ fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
@@ -1276,6 +1508,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
"f442",
|
"f442",
|
||||||
@@ -1302,6 +1535,7 @@ fn put_rejects_unknown_wire_id() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
// 999_999_999 was never allocated → unresolvable.
|
// 999_999_999 was never allocated → unresolvable.
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
@@ -1327,6 +1561,7 @@ fn put_rejects_duplicate_owned_item() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
"f442",
|
"f442",
|
||||||
@@ -1352,6 +1587,7 @@ fn put_core_failure_returns_error_never_python() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||||
let (resp, log) = handle_put_squad(&body, &deps);
|
let (resp, log) = handle_put_squad(&body, &deps);
|
||||||
@@ -1369,6 +1605,7 @@ fn repeated_identical_put_is_idempotent() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
"f442",
|
"f442",
|
||||||
@@ -1405,6 +1642,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
"f442",
|
"f442",
|
||||||
@@ -1510,6 +1748,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
// Commit a squad so Core has a fresh canonical squad + extension.
|
// Commit a squad so Core has a fresh canonical squad + extension.
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
@@ -1557,6 +1796,7 @@ fn read_path_is_bounded_no_per_slot_lookup() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
"f442",
|
"f442",
|
||||||
@@ -1611,6 +1851,7 @@ fn stale_extension_is_not_applied_on_reads() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let (resp, log) = handle_squad_list(&deps);
|
let (resp, log) = handle_squad_list(&deps);
|
||||||
assert_eq!(log.outcome, "stale_integrity");
|
assert_eq!(log.outcome, "stale_integrity");
|
||||||
@@ -1632,6 +1873,7 @@ fn missing_extension_is_explicit_on_reads() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let (resp, log) = handle_squad_list(&deps);
|
let (resp, log) = handle_squad_list(&deps);
|
||||||
assert_eq!(log.outcome, "missing_integrity");
|
assert_eq!(log.outcome, "missing_integrity");
|
||||||
@@ -1653,6 +1895,7 @@ fn usermassinfo_never_serves_python_squad_on_integrity_failure() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let py_body = json!({
|
let py_body = json!({
|
||||||
"userInfo": {"personaId": 7},
|
"userInfo": {"personaId": 7},
|
||||||
@@ -1727,6 +1970,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
|
|||||||
core: &core,
|
core: &core,
|
||||||
resolver: &resolver,
|
resolver: &resolver,
|
||||||
entities: &ent,
|
entities: &ent,
|
||||||
|
squad_actives: false,
|
||||||
};
|
};
|
||||||
let body = put_body(
|
let body = put_body(
|
||||||
"f442",
|
"f442",
|
||||||
@@ -2227,7 +2471,6 @@ fn position_tabs_serve_only_their_own_position_group() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
||||||
for (query, reason) in [
|
for (query, reason) in [
|
||||||
("type=equippables", "multi_family_crash_2026_08_05"),
|
|
||||||
("type=leaguelogos", "subtype_by_elimination_unprobed"),
|
("type=leaguelogos", "subtype_by_elimination_unprobed"),
|
||||||
("type=healing", "served_by_club_consumables_route"),
|
("type=healing", "served_by_club_consumables_route"),
|
||||||
("type=contract", "served_by_club_consumables_route"),
|
("type=contract", "served_by_club_consumables_route"),
|
||||||
@@ -2251,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
|
/// 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
|
/// 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.
|
/// is an empty list rather than a guessed record — and never another family's.
|
||||||
@@ -2588,3 +2860,193 @@ fn every_ownable_class_projects_on_its_own_arm() {
|
|||||||
.all(|i| i["cardsubtypeid"] != 0)
|
.all(|i| i["cardsubtypeid"] != 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Training apply: verb authority and the Core wire contract ────────────────
|
||||||
|
|
||||||
|
/// METHOD IS PART OF ROUTE AUTHORITY. The same `item/resource/<rid>` path means
|
||||||
|
/// three different things, and two of them destroy a card. A training apply that
|
||||||
|
/// slid into the GET arm would read a definition and answer 200 having changed
|
||||||
|
/// nothing; one that slid into the PUT arm would SELL the card the player asked
|
||||||
|
/// to spend. Both were live failure modes before the family was read as one unit.
|
||||||
|
#[test]
|
||||||
|
fn the_item_resource_family_stays_verb_split_for_training_cards() {
|
||||||
|
use openfut_utas_host::{classify_economy, EconomyRoute};
|
||||||
|
|
||||||
|
// 5003011 is a real GK training card (subtype 54, SPEED +10).
|
||||||
|
let path = "/ut/game/fifa17/item/resource/5003011";
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("POST", path),
|
||||||
|
Some(EconomyRoute::ConsumableApply),
|
||||||
|
"POST must be the apply arm"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("PUT", path),
|
||||||
|
Some(EconomyRoute::QuickSellResource),
|
||||||
|
"PUT must remain quick-sell"
|
||||||
|
);
|
||||||
|
// GET is NOT an economy route at all: it is the read-only definition lookup,
|
||||||
|
// so it can never reach the apply transaction.
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("GET", path),
|
||||||
|
None,
|
||||||
|
"GET must not be an economy route"
|
||||||
|
);
|
||||||
|
assert_eq!(classify("GET", path), Route::Passthrough);
|
||||||
|
// The bare tail is the definition lookup Rust does claim.
|
||||||
|
assert_eq!(
|
||||||
|
classify("GET", "/ut/game/fifa17/item/resource"),
|
||||||
|
Route::ItemDefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-numeric or empty resource id must not be mistaken for an apply — the
|
||||||
|
/// digit guard is what keeps `item/resource/anything` from reaching Core.
|
||||||
|
#[test]
|
||||||
|
fn only_a_numeric_resource_id_can_be_applied() {
|
||||||
|
use openfut_utas_host::{classify_economy, EconomyRoute};
|
||||||
|
for tail in ["", "abc", "5003011x", "50030 11"] {
|
||||||
|
let path = format!("/ut/game/fifa17/item/resource/{tail}");
|
||||||
|
assert_ne!(
|
||||||
|
classify_economy("POST", &path),
|
||||||
|
Some(EconomyRoute::ConsumableApply),
|
||||||
|
"tail {tail:?} must not classify as an apply"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effect must serialise EXACTLY as Core's closed `InstanceEffect`
|
||||||
|
/// vocabulary deserialises it. Core dispatches on `kind`, so a drifted token or
|
||||||
|
/// a renamed field is a 400 at best and a silently skipped mutation at worst.
|
||||||
|
#[test]
|
||||||
|
fn the_training_effect_matches_cores_closed_vocabulary() {
|
||||||
|
use openfut_utas_host::ApplyEffect;
|
||||||
|
|
||||||
|
let json = ApplyEffect::ApplyTraining {
|
||||||
|
attribute_index: Some(4),
|
||||||
|
amount: 10,
|
||||||
|
max_amount: 15,
|
||||||
|
}
|
||||||
|
.to_json();
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
serde_json::json!({
|
||||||
|
"kind": "apply_training",
|
||||||
|
"attribute_index": 4,
|
||||||
|
"amount": 10,
|
||||||
|
"max_amount": 15,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// The rare all-six card MUST serialise a null slot. Sending 0 would train
|
||||||
|
// pace alone, and omitting the key would leave Core guessing.
|
||||||
|
let all_six = ApplyEffect::ApplyTraining {
|
||||||
|
attribute_index: None,
|
||||||
|
amount: 10,
|
||||||
|
max_amount: 10,
|
||||||
|
}
|
||||||
|
.to_json();
|
||||||
|
assert_eq!(
|
||||||
|
all_six,
|
||||||
|
serde_json::json!({
|
||||||
|
"kind": "apply_training",
|
||||||
|
"attribute_index": null,
|
||||||
|
"amount": 10,
|
||||||
|
"max_amount": 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// The contract arm must keep its own shape while sharing the enum.
|
||||||
|
let contract = ApplyEffect::AddContractMatches {
|
||||||
|
amount: 3,
|
||||||
|
cap: 99,
|
||||||
|
default_when_unset: 7,
|
||||||
|
}
|
||||||
|
.to_json();
|
||||||
|
assert_eq!(
|
||||||
|
contract,
|
||||||
|
serde_json::json!({
|
||||||
|
"kind": "add_contract_matches",
|
||||||
|
"amount": 3,
|
||||||
|
"cap": 99,
|
||||||
|
"default_when_unset": 7,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every training subtype the adapter can resolve must declare a magnitude no
|
||||||
|
/// larger than the ceiling the host sends Core. If these ever disagree, Core
|
||||||
|
/// refuses a legitimate card — a silent, family-wide outage.
|
||||||
|
#[test]
|
||||||
|
fn no_shipped_training_card_exceeds_the_declared_ceiling() {
|
||||||
|
use openfut_adapter_fifa17::fut::training_cards::{
|
||||||
|
ceiling_for, training_effect, TRAINING_ALL_MAX_AMOUNT, TRAINING_MAX_AMOUNT,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut resolved = 0;
|
||||||
|
for subtype in [51, 52, 53, 54, 55, 56, 61, 62, 63, 64, 65, 66] {
|
||||||
|
for amount in [5, 10, 15] {
|
||||||
|
let e = training_effect(subtype, Some(amount)).expect("shipped card resolves");
|
||||||
|
assert!(
|
||||||
|
e.amount <= ceiling_for(&e),
|
||||||
|
"subtype {subtype} amount {amount} exceeds the ceiling sent to Core"
|
||||||
|
);
|
||||||
|
assert_eq!(ceiling_for(&e), TRAINING_MAX_AMOUNT);
|
||||||
|
resolved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(resolved, 36, "all 36 single-attribute cards must resolve");
|
||||||
|
|
||||||
|
// The six rare all-six cards carry their own, lower ceiling.
|
||||||
|
let mut rare = 0;
|
||||||
|
for subtype in [57, 67] {
|
||||||
|
for amount in [3, 6, 10] {
|
||||||
|
let e = training_effect(subtype, Some(amount)).expect("rare card resolves");
|
||||||
|
assert_eq!(e.attribute_index, None);
|
||||||
|
assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT);
|
||||||
|
rare += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Executable
+176
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Smoke-test the REAL consumable-apply route against the RUNNING staging host.
|
||||||
|
|
||||||
|
`POST ut/<sku>/item/resource/<resourceId>` is now Rust-owned and it MUTATES:
|
||||||
|
Core destroys the source consumable instance and raises the target's
|
||||||
|
match-contracts in ONE transaction. There is no `OPENFUT_FIFA17_APPLY_PROBE`
|
||||||
|
gate any more and no staging-only diagnostic -- the route is unconditional --
|
||||||
|
so what needs proving changed. This checks the two halves that matter:
|
||||||
|
|
||||||
|
* every REFUSAL leaves Core byte-identical (fail closed; nothing half-applied);
|
||||||
|
* the one accepted apply consumes exactly ONE copy, and the contract number the
|
||||||
|
host logged is the number the client can actually read back off the wire.
|
||||||
|
|
||||||
|
It deliberately does NOT re-derive the FIFA 17 grant matrix. Duplicating those
|
||||||
|
13 rows here would create a second source of truth that could silently disagree
|
||||||
|
with `openfut-adapter-fifa17::fut::contract_cards`, which is the authority.
|
||||||
|
Instead the host's own `granted/before/after` are checked for internal
|
||||||
|
consistency (`after == min(99, before + granted)`) and against the projected
|
||||||
|
wire state.
|
||||||
|
|
||||||
|
Replaying the accepted request is NOT idempotent and is not attempted: a
|
||||||
|
successful apply DESTROYS the source instance, so a second POST legitimately
|
||||||
|
consumes the NEXT owned copy. Idempotency is Core's, keyed on the source
|
||||||
|
instance id (`fifa17:apply:<source>-><target>`), and a transport retry of one
|
||||||
|
logical action replays that key rather than this HTTP request.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8299"
|
||||||
|
SKU = "fifa17"
|
||||||
|
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
|
||||||
|
SNAPSHOT = "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"
|
||||||
|
|
||||||
|
# A PLAYER contract card (cardsubtypeid 201) owned on staging: the one accepted
|
||||||
|
# apply. It must match the snapshot script's SOURCE_RES so the copy count below
|
||||||
|
# is the count of THIS stack.
|
||||||
|
CONTRACT_RES = 5001004
|
||||||
|
# Position modifier: a real owned family whose apply effect is NOT proven.
|
||||||
|
POSITION_RES = 5003068
|
||||||
|
# Manager contract (cardsubtypeid 202): unservable until staff ratings are
|
||||||
|
# imported, because the grant is keyed on the TARGET's rating tier.
|
||||||
|
MANAGER_RES = 5001010
|
||||||
|
UNOWNED_RES = 1234567
|
||||||
|
TARGET_WIRE = 100000003
|
||||||
|
UNKNOWN_WIRE = 999999999
|
||||||
|
H = {"X-OpenFUT-Game": SKU, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
|
||||||
|
def post(resource_id, wires):
|
||||||
|
body = json.dumps({"apply": [{"id": w} for w in wires]}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"%s/ut/game/%s/item/resource/%d" % (BASE, SKU, resource_id),
|
||||||
|
data=body, headers=H, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, r.read().decode()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot():
|
||||||
|
out = subprocess.run(["python3", SNAPSHOT], capture_output=True, text=True)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise SystemExit("snapshot failed: %s" % out.stderr.strip())
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_log_since(mark):
|
||||||
|
with open(LOG) as f:
|
||||||
|
return [ln.rstrip() for ln in list(f)[mark:] if "consumable-apply" in ln]
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok, detail=""):
|
||||||
|
results.append(ok)
|
||||||
|
print(" [%s] %s%s" % ("OK" if ok else "FAIL", name,
|
||||||
|
(" -- " + detail) if detail else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def skip(name, detail):
|
||||||
|
print(" [SKIP] %s -- %s" % (name, detail))
|
||||||
|
|
||||||
|
|
||||||
|
def stack_count(snap):
|
||||||
|
"""Owned copies of CONTRACT_RES, or 0 once the last one is consumed (the
|
||||||
|
stack disappears from the consumables screen entirely)."""
|
||||||
|
return ((snap.get("source_stack") or {}).get("count")) or 0
|
||||||
|
|
||||||
|
|
||||||
|
with open(LOG) as f:
|
||||||
|
mark = sum(1 for _ in f)
|
||||||
|
base = snapshot()
|
||||||
|
|
||||||
|
print("=== every refusal must leave Core byte-identical ===")
|
||||||
|
REFUSALS = [
|
||||||
|
("batch semantics unproven", CONTRACT_RES,
|
||||||
|
[TARGET_WIRE, TARGET_WIRE + 1], 400, "apply_batch_unsupported"),
|
||||||
|
("unknown target wire id", CONTRACT_RES,
|
||||||
|
[UNKNOWN_WIRE], 404, "not_owned"),
|
||||||
|
("source consumable not owned", UNOWNED_RES,
|
||||||
|
[TARGET_WIRE], 404, "not_owned"),
|
||||||
|
("non-contract family fails closed", POSITION_RES,
|
||||||
|
[TARGET_WIRE], 409, "apply_effect_unproven"),
|
||||||
|
("manager contract refused: staff ratings not imported", MANAGER_RES,
|
||||||
|
[TARGET_WIRE], 409, "manager_contract_unsupported"),
|
||||||
|
]
|
||||||
|
for name, res, wires, want_status, want_code in REFUSALS:
|
||||||
|
status, body = post(res, wires)
|
||||||
|
got = "status=%s body=%s" % (status, body[:80])
|
||||||
|
# A family-gate case can only be exercised if this profile owns such a card;
|
||||||
|
# source resolution runs first, so an unowned one answers 404 not_owned. Say
|
||||||
|
# so rather than scoring a pass or a failure that means nothing.
|
||||||
|
if want_status != 404 and status == 404 and "not_owned" in body:
|
||||||
|
skip(name, "profile owns no resource %d" % res)
|
||||||
|
continue
|
||||||
|
check(name, status == want_status and want_code in body, got)
|
||||||
|
check("Core unchanged by every refusal", snapshot() == base)
|
||||||
|
|
||||||
|
print("\n=== the one accepted apply must mutate, exactly once ===")
|
||||||
|
status, body = post(CONTRACT_RES, [TARGET_WIRE])
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
check("client-shaped ack", status == 200 and parsed == {"itemData": []},
|
||||||
|
"status=%s body=%s" % (status, body[:80]))
|
||||||
|
after = snapshot()
|
||||||
|
lines = apply_log_since(mark)
|
||||||
|
granted_lines = [ln for ln in lines if "granted=" in ln]
|
||||||
|
fields = {}
|
||||||
|
if granted_lines:
|
||||||
|
fields = dict(re.findall(r"(subtype|granted|before|after|applied)=(-?\w+)",
|
||||||
|
granted_lines[-1]))
|
||||||
|
check("host logged the grant it applied",
|
||||||
|
{"subtype", "granted", "before", "after"} <= set(fields),
|
||||||
|
granted_lines[-1] if granted_lines else "no consumable-apply grant line")
|
||||||
|
|
||||||
|
if {"subtype", "granted", "before", "after"} <= set(fields):
|
||||||
|
granted = int(fields["granted"])
|
||||||
|
before_n = int(fields["before"])
|
||||||
|
after_n = int(fields["after"])
|
||||||
|
check("player contract subtype", fields["subtype"] == "201",
|
||||||
|
"subtype=%s" % fields["subtype"])
|
||||||
|
check("Core applied a real grant", granted > 0, "granted=%d" % granted)
|
||||||
|
check("cap respected: after == min(99, before + granted)",
|
||||||
|
after_n == min(99, before_n + granted),
|
||||||
|
"before=%d granted=%d after=%d" % (before_n, granted, after_n))
|
||||||
|
check("not a replay", fields.get("applied") == "true",
|
||||||
|
"applied=%s" % fields.get("applied"))
|
||||||
|
wire_contract = (after.get("target") or {}).get("contract")
|
||||||
|
check("the wire shows what Core recorded", wire_contract == after_n,
|
||||||
|
"wire=%s core=%s" % (wire_contract, after_n))
|
||||||
|
|
||||||
|
check("exactly one source copy consumed",
|
||||||
|
stack_count(after) == stack_count(base) - 1,
|
||||||
|
"before=%s after=%s" % (stack_count(base), stack_count(after)))
|
||||||
|
check("exactly one owned row destroyed",
|
||||||
|
after["owned_rows"] == base["owned_rows"] - 1,
|
||||||
|
"before=%s after=%s" % (base["owned_rows"], after["owned_rows"]))
|
||||||
|
check("coins untouched: an apply is not a sale",
|
||||||
|
after["coins"] == base["coins"],
|
||||||
|
"before=%s after=%s" % (base["coins"], after["coins"]))
|
||||||
|
|
||||||
|
print("\n=== host log ===")
|
||||||
|
for line in lines:
|
||||||
|
print(" " + line[:200])
|
||||||
|
|
||||||
|
ok = all(results)
|
||||||
|
print("\nRESULT: %s" % ("OK" if ok else "FAILED"))
|
||||||
|
raise SystemExit(0 if ok else 1)
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Core snapshot around a consumable APPLY: coins, ownership, the source
|
||||||
|
consumable's copies, and the target's mutable state. Run before and after.
|
||||||
|
|
||||||
|
A player-contract apply must move EXACTLY three things: the source consumable
|
||||||
|
loses one copy, the target's `contract` rises to min(99, before + grant), and
|
||||||
|
nothing else — coins in particular must not move, because an apply is not an
|
||||||
|
economy credit. Everything else in this snapshot is here to prove it stayed put.
|
||||||
|
|
||||||
|
(Superseded for acceptance by fifa17-contract-apply-validate.py, which asserts
|
||||||
|
the deltas itself; this remains the raw before/after dump for eyeballing.)"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
H = {"X-OpenFUT-Game": "fifa17"}
|
||||||
|
SOURCE_RES = 5001004
|
||||||
|
TARGET_WIRE = 100000003
|
||||||
|
|
||||||
|
|
||||||
|
def get(p):
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:8299" + p, headers=H)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
snap = {}
|
||||||
|
import sqlite3
|
||||||
|
con = sqlite3.connect("file:/home/alex/openfut-sold-staging/staging-core.db?mode=ro", uri=True)
|
||||||
|
snap["coins"] = con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0]
|
||||||
|
snap["owned_rows"] = con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0]
|
||||||
|
snap["by_kind"] = dict(con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1"))
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
cons = get("/ut/game/fifa17/club/consumables/development")
|
||||||
|
stacks = cons.get("itemData") or []
|
||||||
|
snap["development_stacks"] = len(stacks)
|
||||||
|
snap["development_copies"] = sum(s.get("count", 0) for s in stacks)
|
||||||
|
snap["source_stack"] = next(
|
||||||
|
({"count": s.get("count"), "resourceId": s.get("resourceId")}
|
||||||
|
for s in stacks if s.get("resourceId") == SOURCE_RES), None)
|
||||||
|
|
||||||
|
sq = get("/ut/game/fifa17/squad/0")
|
||||||
|
players = (sq.get("squad") or sq).get("players") or []
|
||||||
|
for p in players:
|
||||||
|
it = p.get("itemData") or {}
|
||||||
|
if it.get("id") == TARGET_WIRE:
|
||||||
|
snap["target"] = {
|
||||||
|
"resourceId": it.get("resourceId"), "rating": it.get("rating"),
|
||||||
|
"contract": it.get("contract"), "fitness": it.get("fitness"),
|
||||||
|
"injuryType": it.get("injuryType"), "training": it.get("training"),
|
||||||
|
"playStyle": it.get("playStyle"), "preferredPosition": it.get("preferredPosition"),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
print(json.dumps(snap, indent=2, sort_keys=True))
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
open(sys.argv[1], "w").write(json.dumps(snap, sort_keys=True))
|
||||||
Executable
+227
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Acceptance harness for FIFA17 player-contract consumable APPLY (staging).
|
||||||
|
|
||||||
|
Exercises the real route end to end and asserts the full observable contract:
|
||||||
|
|
||||||
|
* the target's `contract` goes from B to min(99, B + grant), where `grant` is
|
||||||
|
read from the shipped EA table `fcc_contractcards` INDEPENDENTLY of the Rust
|
||||||
|
implementation (this is a cross-check, not a mirror);
|
||||||
|
* exactly one source copy is consumed;
|
||||||
|
* coins do NOT move (an apply is not an economy credit);
|
||||||
|
* replaying the exhausted resource fails closed rather than granting again;
|
||||||
|
* a manager contract and a non-contract consumable both fail closed.
|
||||||
|
|
||||||
|
Defaults target STAGING. `--host`/`--db` retarget it at a rehearsal or, under
|
||||||
|
explicit authorization, at the production acceptance run. There is deliberately
|
||||||
|
no production default: a bare invocation cannot touch production.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Defaults are STAGING. Override for a rehearsal or the production acceptance
|
||||||
|
# run; there is deliberately no production default, so a bare invocation can
|
||||||
|
# never touch production by accident.
|
||||||
|
HOST = "http://127.0.0.1:8299"
|
||||||
|
CORE_DB = "/home/alex/openfut-sold-staging/staging-core.db"
|
||||||
|
HDRS = {"X-OpenFUT-Game": "fifa17"}
|
||||||
|
TABLE = "/home/alex/OpenFUT/fifa17-recon/data/tables/fcc_contractcards.json"
|
||||||
|
|
||||||
|
PLAYER_CONTRACT_SUBTYPE = 201
|
||||||
|
MANAGER_CONTRACT_SUBTYPE = 202
|
||||||
|
CAP = 99
|
||||||
|
|
||||||
|
FAILURES = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, got, want):
|
||||||
|
ok = got == want
|
||||||
|
print(f" [{'OK ' if ok else 'FAIL'}] {label}: got {got!r} want {want!r}")
|
||||||
|
if not ok:
|
||||||
|
FAILURES.append(label)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def req(method, path, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(HOST + path, data=data, headers=HDRS, method=method)
|
||||||
|
if data:
|
||||||
|
r.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=30) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return resp.status, (json.loads(raw) if raw else None)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return e.code, raw.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def grant_from_table(resource_id, target_rating):
|
||||||
|
"""The authoritative grant, read straight from EA's shipped table.
|
||||||
|
|
||||||
|
Column is selected by the TARGET's tier (bronze <65, silver 65..74, gold
|
||||||
|
>=75) — NOT by the card's own tier. Verified 36/36 against the published
|
||||||
|
FIFA 17 matrix.
|
||||||
|
"""
|
||||||
|
d = json.load(open(TABLE))
|
||||||
|
rows = d if isinstance(d, list) else (d.get("rows") or list(d.values())[0])
|
||||||
|
row = next((r for r in rows if r["carddbid"] == resource_id), None)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
col = "bronze" if target_rating < 65 else ("silver" if target_rating < 75 else "gold")
|
||||||
|
return row[col]
|
||||||
|
|
||||||
|
|
||||||
|
def core_snapshot():
|
||||||
|
con = sqlite3.connect(f"file:{CORE_DB}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
snap = {
|
||||||
|
"coins": con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0],
|
||||||
|
"owned": con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0],
|
||||||
|
"by_kind": dict(
|
||||||
|
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
|
||||||
|
snap["has_contract_column"] = "contract_matches" in cols
|
||||||
|
if snap["has_contract_column"]:
|
||||||
|
snap["contracts_set"] = con.execute(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
return snap
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def club_players():
|
||||||
|
"""Every owned player on the wire, with its contract, keyed by wire id."""
|
||||||
|
out = {}
|
||||||
|
for pile in ("/ut/game/fifa17/club?type=player&start=0&count=200",):
|
||||||
|
_, body = req("GET", pile)
|
||||||
|
for it in (body or {}).get("itemData") or []:
|
||||||
|
if it.get("itemType") == "player":
|
||||||
|
out[it["id"]] = it
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def consumable_stacks():
|
||||||
|
_, body = req("GET", "/ut/game/fifa17/club/consumables/development")
|
||||||
|
return {s["resourceId"]: s for s in (body or {}).get("itemData") or []}
|
||||||
|
|
||||||
|
|
||||||
|
def pick_source(stacks, subtype_range):
|
||||||
|
for rid, s in sorted(stacks.items()):
|
||||||
|
if rid in subtype_range:
|
||||||
|
return rid, s
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--source", type=int, default=None,
|
||||||
|
help="contract resource id to apply (default: first owned player contract)")
|
||||||
|
ap.add_argument("--target", type=int, default=None,
|
||||||
|
help="target player wire id (default: lowest-rated owned player)")
|
||||||
|
ap.add_argument("--host", default=HOST, help=f"utas-host base URL (default {HOST})")
|
||||||
|
ap.add_argument("--db", default=CORE_DB, help=f"Core SQLite path (default {CORE_DB})")
|
||||||
|
args = ap.parse_args()
|
||||||
|
# Rebound before any request so every helper below reads the chosen target.
|
||||||
|
globals()["HOST"] = args.host
|
||||||
|
globals()["CORE_DB"] = args.db
|
||||||
|
print(f"target host : {HOST}\ntarget db : {CORE_DB}\n")
|
||||||
|
|
||||||
|
print("== BEFORE ==")
|
||||||
|
before = core_snapshot()
|
||||||
|
print(json.dumps(before, indent=2, sort_keys=True))
|
||||||
|
if not before["has_contract_column"]:
|
||||||
|
print("FATAL: migration 0028 not applied — owned_cards has no contract_matches column")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
stacks = consumable_stacks()
|
||||||
|
players = club_players()
|
||||||
|
if not players:
|
||||||
|
print("FATAL: no owned players on the wire")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
player_contracts = {r: s for r, s in stacks.items() if 5001001 <= r <= 5001006 or r == 5001013}
|
||||||
|
src = args.source or next(iter(sorted(player_contracts)), None)
|
||||||
|
if src is None:
|
||||||
|
print("FATAL: no owned PLAYER contract consumable to apply")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Lowest-rated target maximises the observable delta (a bronze target draws
|
||||||
|
# the largest column) and exercises the tier selector rather than assuming gold.
|
||||||
|
tgt_id = args.target or min(players, key=lambda i: players[i].get("rating", 0))
|
||||||
|
tgt = players[tgt_id]
|
||||||
|
rating = tgt["rating"]
|
||||||
|
grant = grant_from_table(src, rating)
|
||||||
|
c_before = tgt.get("contract")
|
||||||
|
expect_after = min(CAP, c_before + grant)
|
||||||
|
|
||||||
|
print(f"\n== APPLY ==\n source resource {src} (stack count {stacks[src].get('count')})")
|
||||||
|
print(f" target wire {tgt_id} rating {rating} -> tier "
|
||||||
|
f"{'bronze' if rating < 65 else 'silver' if rating < 75 else 'gold'}")
|
||||||
|
print(f" table grant {grant}; contract {c_before} -> expect {expect_after}")
|
||||||
|
|
||||||
|
status, body = req("POST", f"/ut/game/fifa17/item/resource/{src}",
|
||||||
|
{"apply": [{"id": tgt_id}]})
|
||||||
|
print(f" HTTP {status} {json.dumps(body)[:200] if body is not None else ''}")
|
||||||
|
check("apply status", status, 200)
|
||||||
|
check("apply body", body, {"itemData": []})
|
||||||
|
|
||||||
|
print("\n== AFTER ==")
|
||||||
|
after = core_snapshot()
|
||||||
|
players2 = club_players()
|
||||||
|
stacks2 = consumable_stacks()
|
||||||
|
print(json.dumps(after, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
check("coins unchanged", after["coins"], before["coins"])
|
||||||
|
check("one owned row consumed", after["owned"], before["owned"] - 1)
|
||||||
|
check("one consumable consumed",
|
||||||
|
after["by_kind"].get("consumable", 0), before["by_kind"].get("consumable", 0) - 1)
|
||||||
|
check("target contract granted", players2.get(tgt_id, {}).get("contract"), expect_after)
|
||||||
|
check("source stack decremented",
|
||||||
|
(stacks2.get(src) or {}).get("count", 0), (stacks[src].get("count") or 1) - 1)
|
||||||
|
|
||||||
|
# Untouched players must not have drifted.
|
||||||
|
drifted = [i for i, p in players2.items()
|
||||||
|
if i != tgt_id and p.get("contract") != players.get(i, {}).get("contract")]
|
||||||
|
check("no collateral contract changes", drifted, [])
|
||||||
|
|
||||||
|
print("\n== FAIL-CLOSED CASES ==")
|
||||||
|
exhausted = (stacks2.get(src) or {}).get("count", 0) == 0
|
||||||
|
if exhausted:
|
||||||
|
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}", {"apply": [{"id": tgt_id}]})
|
||||||
|
check("replay of exhausted resource refused", s, 404)
|
||||||
|
|
||||||
|
mgr = next((r for r in stacks2 if 5001007 <= r <= 5001012), None)
|
||||||
|
if mgr:
|
||||||
|
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{mgr}", {"apply": [{"id": tgt_id}]})
|
||||||
|
check("manager contract fails closed (staff ratings unimported)", s, 409)
|
||||||
|
|
||||||
|
other = next((r for r in stacks2 if not (5001001 <= r <= 5001013)), None)
|
||||||
|
if other:
|
||||||
|
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{other}", {"apply": [{"id": tgt_id}]})
|
||||||
|
check("unproven family fails closed", s, 409)
|
||||||
|
|
||||||
|
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}",
|
||||||
|
{"apply": [{"id": tgt_id}, {"id": tgt_id}]})
|
||||||
|
check("batch apply refused", s in (400, 404), True)
|
||||||
|
|
||||||
|
print("\n== RESULT ==")
|
||||||
|
if FAILURES:
|
||||||
|
print("FAILED: " + ", ".join(FAILURES))
|
||||||
|
return 1
|
||||||
|
print(f"PASS — contract {c_before} -> {expect_after} on wire {tgt_id}, "
|
||||||
|
f"one copy of {src} consumed, coins flat")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pre/post invariants for the FIFA17 Rust production migration rehearsal.
|
||||||
|
|
||||||
|
Covers every domain the authorization names: coins, ownership, squads, managers,
|
||||||
|
staff, consumables, club items, transfer state, packs/unassigned, SBC state,
|
||||||
|
match state/history. Read-only; run against a COPY.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
db = sys.argv[1]
|
||||||
|
out = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
con = sqlite3.connect("file:%s?mode=ro" % db, uri=True)
|
||||||
|
tables = {r[0] for r in con.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
|
||||||
|
|
||||||
|
def one(sql, default=None):
|
||||||
|
try:
|
||||||
|
r = con.execute(sql).fetchone()
|
||||||
|
return r[0] if r else default
|
||||||
|
except sqlite3.Error:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def count(t):
|
||||||
|
return one("SELECT COUNT(*) FROM %s" % t) if t in tables else None
|
||||||
|
|
||||||
|
|
||||||
|
def group(t, col):
|
||||||
|
if t not in tables:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dict(con.execute("SELECT %s, COUNT(*) FROM %s GROUP BY 1" % (col, t)))
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
inv = {
|
||||||
|
"schema_version": one("SELECT MAX(version) FROM _sqlx_migrations"),
|
||||||
|
"tables": len(tables),
|
||||||
|
# economy
|
||||||
|
"coins": one("SELECT coins FROM clubs LIMIT 1"),
|
||||||
|
"clubs": count("clubs"),
|
||||||
|
"profiles": count("profiles"),
|
||||||
|
# ownership
|
||||||
|
"owned_cards": count("owned_cards"),
|
||||||
|
"owned_by_kind": group("owned_cards", "content_kind"),
|
||||||
|
"owned_distinct_cards": one("SELECT COUNT(DISTINCT card_id) FROM owned_cards"),
|
||||||
|
"owned_loans": one("SELECT COUNT(*) FROM owned_cards WHERE is_loan=1"),
|
||||||
|
"owned_chem_styles": one(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards WHERE chemistry_style IS NOT NULL"),
|
||||||
|
"owned_pos_overrides": one(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards WHERE position_override IS NOT NULL"),
|
||||||
|
# squads / managers
|
||||||
|
"squads": count("squads"),
|
||||||
|
"squad_managers": count("squad_managers"),
|
||||||
|
# club items
|
||||||
|
"club_active_items": count("club_active_items"),
|
||||||
|
"club_kit_assignments": count("club_kit_assignments"),
|
||||||
|
# transfer / market / piles
|
||||||
|
"market_listings": count("market_listings"),
|
||||||
|
"market_listings_by_status": group("market_listings", "status"),
|
||||||
|
"market_history": count("market_history"),
|
||||||
|
"packs": count("packs"),
|
||||||
|
# FIFA17 opaque squad extension (adapter-owned blob)
|
||||||
|
"game_entity_ext": count("game_entity_ext"),
|
||||||
|
"squad_players": count("squad_players"),
|
||||||
|
"seasons": count("seasons"),
|
||||||
|
"events": count("events"),
|
||||||
|
"notifications": count("notifications"),
|
||||||
|
# sbc
|
||||||
|
"sbc_submissions": count("sbc_submissions"),
|
||||||
|
"sbc_challenge_squads": count("sbc_challenge_squads"),
|
||||||
|
# matches / history
|
||||||
|
"matches": count("matches"),
|
||||||
|
"match_completions": count("match_completions"),
|
||||||
|
"season_history": count("season_history"),
|
||||||
|
"statistics": count("statistics"),
|
||||||
|
"consumable_applications": count("consumable_applications"),
|
||||||
|
}
|
||||||
|
# integrity
|
||||||
|
inv["integrity_check"] = one("PRAGMA integrity_check")
|
||||||
|
inv["foreign_key_violations"] = len(list(con.execute("PRAGMA foreign_key_check")))
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
print(json.dumps(inv, indent=2, sort_keys=True))
|
||||||
|
if out:
|
||||||
|
open(out, "w").write(json.dumps(inv, sort_keys=True, indent=2))
|
||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Serve the MIGRATED + RECLASSIFIED rehearsal copy with the candidate Rust
|
||||||
|
stack and validate the wire surface. Isolated ports, copy-only state, no
|
||||||
|
production or staging resource touched."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
R = "/home/alex/openfut-migration/rehearsal-20260822"
|
||||||
|
REPO = "/home/alex/OpenFUT"
|
||||||
|
CORE_PORT, HOST_PORT = 18099, 18098
|
||||||
|
H = {"X-OpenFUT-Game": "fifa17"}
|
||||||
|
procs = []
|
||||||
|
|
||||||
|
|
||||||
|
def start(name, binary, env):
|
||||||
|
e = dict(os.environ)
|
||||||
|
e.update(env)
|
||||||
|
log = open("%s/evidence/%s.log" % (R, name), "w")
|
||||||
|
p = subprocess.Popen([binary], cwd=REPO, env=e, stdout=log, stderr=log,
|
||||||
|
start_new_session=False)
|
||||||
|
procs.append(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def get(port, path):
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), headers=H)
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
start("rehearsal-core", R + "/artifacts/openfut-core", {
|
||||||
|
"LISTEN_ADDR": "127.0.0.1:%d" % CORE_PORT,
|
||||||
|
"DATABASE_URL": "sqlite://%s/work/core.db" % R,
|
||||||
|
"DATA_DIR": "%s/openfut-core/data" % REPO,
|
||||||
|
"OPENFUT_CONTENT_PACKS": "%s/emit/content/fifa17-production-cards.json" % R,
|
||||||
|
"RUST_LOG": "warn",
|
||||||
|
})
|
||||||
|
time.sleep(6)
|
||||||
|
start("rehearsal-host", R + "/artifacts/openfut-utas-host", {
|
||||||
|
"OPENFUT_UTAS_HOST_ADDR": "127.0.0.1:%d" % HOST_PORT,
|
||||||
|
"OPENFUT_UTAS_PYTHON_URL": "http://127.0.0.1:19999", # deliberately dead
|
||||||
|
"OPENFUT_CORE_URL": "http://127.0.0.1:%d" % CORE_PORT,
|
||||||
|
"OPENFUT_FIFA17_TABLES_DIR": "%s/fifa17-recon/data/tables" % REPO,
|
||||||
|
"OPENFUT_FIFA17_CATALOG": "%s/emit/content/fifa17-production-catalog.json" % R,
|
||||||
|
"OPENFUT_IDENTITY_STORE": "%s/work/identity.json" % R,
|
||||||
|
"OPENFUT_PERSONA_ID": "33068179",
|
||||||
|
"OPENFUT_MARKET_DB": "%s/work/market.db" % R,
|
||||||
|
"OPENFUT_PILE_DB": "%s/work/pile.db" % R,
|
||||||
|
"OPENFUT_FIFA17_DISCARD_TABLE": "1",
|
||||||
|
"RUST_LOG": "warn",
|
||||||
|
})
|
||||||
|
time.sleep(6)
|
||||||
|
|
||||||
|
checks = {}
|
||||||
|
club = get(HOST_PORT, "/ut/game/fifa17/club?type=player&start=0&count=5")
|
||||||
|
checks["club_total"] = club.get("totalResults")
|
||||||
|
checks["club_first_discard"] = (club.get("itemData") or [{}])[0].get("discardValue")
|
||||||
|
checks["club_first_rating"] = (club.get("itemData") or [{}])[0].get("rating")
|
||||||
|
|
||||||
|
umi = get(HOST_PORT, "/ut/game/fifa17/userMassInfo")
|
||||||
|
checks["squad_slots"] = len(((umi.get("userInfo") or {}).get("squad") or
|
||||||
|
umi.get("squad") or {}).get("players", []) or [])
|
||||||
|
|
||||||
|
for cat in ("contracts", "development", "fitness"):
|
||||||
|
d = get(HOST_PORT, "/ut/game/fifa17/club/consumables/" + cat)
|
||||||
|
st = d.get("itemData") or []
|
||||||
|
checks["consumables_" + cat] = (len(st), sum(s.get("count", 0) for s in st))
|
||||||
|
|
||||||
|
sq = get(HOST_PORT, "/ut/game/fifa17/squad/0")
|
||||||
|
checks["squad_players"] = len((sq.get("squad") or sq).get("players") or [])
|
||||||
|
|
||||||
|
print(json.dumps(checks, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
# The consumable apply is Rust-owned and MUTATES, so the rehearsal must not
|
||||||
|
# send one that would succeed: a two-target body is refused (400
|
||||||
|
# apply_batch_unsupported) before anything is resolved or written. That
|
||||||
|
# refusal is only reachable if the candidate host CLAIMS the route -- a 502
|
||||||
|
# means it fell through to the (dead) upstream, i.e. the cutover is missing.
|
||||||
|
print("\n=== the candidate must OWN the consumable apply ===")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"http://127.0.0.1:%d/ut/game/fifa17/item/resource/5001004" % HOST_PORT,
|
||||||
|
data=b'{"apply":[{"id":100000003},{"id":100000004}]}',
|
||||||
|
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
|
||||||
|
method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
st, body = r.status, r.read().decode()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
st, body = e.code, e.read().decode()
|
||||||
|
print(" status=%s body=%s" % (st, body))
|
||||||
|
print(" apply route Rust-owned, nothing mutated (must be 400 "
|
||||||
|
"apply_batch_unsupported): %s"
|
||||||
|
% (st == 400 and "apply_batch_unsupported" in body))
|
||||||
|
finally:
|
||||||
|
for p in procs:
|
||||||
|
try:
|
||||||
|
os.kill(p.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
for p in procs:
|
||||||
|
if p.poll() is None:
|
||||||
|
os.kill(p.pid, signal.SIGKILL)
|
||||||
|
print("\nrehearsal processes stopped")
|
||||||
Executable
+158
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Transactionally-consistent backup of a live OpenFUT Core SQLite DB.
|
||||||
|
|
||||||
|
WHY NOT `cp`. Production runs WAL mode with an uncheckpointed WAL that routinely
|
||||||
|
holds hundreds of KB of committed pages. Copying only `prod-core.db` captures the
|
||||||
|
main file WITHOUT those pages and silently loses committed transactions; copying
|
||||||
|
the three files non-atomically can capture a torn set. This uses SQLite's online
|
||||||
|
backup API, which walks a read transaction and emits ONE standalone, already-
|
||||||
|
merged database file — no sidecar needed, no writer paused, safe against a live
|
||||||
|
production process.
|
||||||
|
|
||||||
|
RESTORE HAZARD, read this before restoring. The destination of a restore MUST
|
||||||
|
have its `-wal` and `-shm` removed first. SQLite treats an existing `-wal` as
|
||||||
|
newer-than-the-database journal content and will replay it over the file you
|
||||||
|
just put back, resurrecting exactly the state you were trying to abandon. The
|
||||||
|
emitted `RESTORE.sh` does this in the right order.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
fifa17-promotion-backup.py <source-db> <backup-dir> [--label NAME]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def describe(db_path, read_only=True):
|
||||||
|
uri = f"file:{db_path}?mode=ro" if read_only else db_path
|
||||||
|
con = sqlite3.connect(uri, uri=True)
|
||||||
|
one = lambda s: con.execute(s).fetchone()[0]
|
||||||
|
info = {
|
||||||
|
"journal_mode": one("PRAGMA journal_mode"),
|
||||||
|
"page_size": one("PRAGMA page_size"),
|
||||||
|
"page_count": one("PRAGMA page_count"),
|
||||||
|
"migration_max": one("SELECT MAX(version) FROM _sqlx_migrations"),
|
||||||
|
"migration_count": one("SELECT COUNT(*) FROM _sqlx_migrations"),
|
||||||
|
"integrity_check": one("PRAGMA integrity_check"),
|
||||||
|
"foreign_key_check": len(con.execute("PRAGMA foreign_key_check").fetchall()),
|
||||||
|
"owned_cards": one("SELECT COUNT(*) FROM owned_cards"),
|
||||||
|
"coins": one("SELECT COALESCE(SUM(coins), 0) FROM clubs"),
|
||||||
|
}
|
||||||
|
con.close()
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("source")
|
||||||
|
ap.add_argument("backup_dir")
|
||||||
|
ap.add_argument("--label", default="core")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
os.makedirs(args.backup_dir, exist_ok=True)
|
||||||
|
dest = os.path.join(args.backup_dir, f"{args.label}-{stamp}.db")
|
||||||
|
|
||||||
|
src_info = describe(args.source)
|
||||||
|
sidecars = {
|
||||||
|
os.path.basename(args.source) + suf:
|
||||||
|
(os.path.getsize(args.source + suf) if os.path.exists(args.source + suf) else None)
|
||||||
|
for suf in ("", "-wal", "-shm")
|
||||||
|
}
|
||||||
|
print(f"source : {args.source}")
|
||||||
|
print(f" journal : {src_info['journal_mode']} migration_max={src_info['migration_max']}")
|
||||||
|
print(f" sidecars : {sidecars}")
|
||||||
|
print(f" integrity : {src_info['integrity_check']} fk_violations={src_info['foreign_key_check']}")
|
||||||
|
|
||||||
|
# Online backup API. Source opened READ-ONLY: production is never written to,
|
||||||
|
# and in WAL mode this does not block the live writer.
|
||||||
|
src = sqlite3.connect(f"file:{args.source}?mode=ro", uri=True)
|
||||||
|
dst = sqlite3.connect(dest)
|
||||||
|
with dst:
|
||||||
|
src.backup(dst)
|
||||||
|
dst.close()
|
||||||
|
src.close()
|
||||||
|
os.chmod(dest, stat.S_IRUSR | stat.S_IRGRP) # read-only: a backup is not scratch space
|
||||||
|
|
||||||
|
dst_info = describe(dest)
|
||||||
|
digest = sha256(dest)
|
||||||
|
|
||||||
|
# The backup is only a backup if it independently verifies. A mismatch here
|
||||||
|
# means DO NOT PROCEED — it does not mean "retry and hope".
|
||||||
|
checks = {
|
||||||
|
"integrity_ok": dst_info["integrity_check"] == "ok",
|
||||||
|
"no_fk_violations": dst_info["foreign_key_check"] == 0,
|
||||||
|
"migration_matches": dst_info["migration_max"] == src_info["migration_max"],
|
||||||
|
"owned_matches": dst_info["owned_cards"] == src_info["owned_cards"],
|
||||||
|
"coins_match": dst_info["coins"] == src_info["coins"],
|
||||||
|
}
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"taken_at": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"source": os.path.abspath(args.source),
|
||||||
|
"source_sidecar_sizes": sidecars,
|
||||||
|
"source_info": src_info,
|
||||||
|
"backup_path": os.path.abspath(dest),
|
||||||
|
"backup_sha256": digest,
|
||||||
|
"backup_size": os.path.getsize(dest),
|
||||||
|
"backup_info": dst_info,
|
||||||
|
"verification": checks,
|
||||||
|
"method": "sqlite3 online backup API (Connection.backup), source opened mode=ro",
|
||||||
|
}
|
||||||
|
meta_path = dest + ".json"
|
||||||
|
open(meta_path, "w").write(json.dumps(meta, indent=2, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
restore = os.path.join(args.backup_dir, f"RESTORE-{args.label}-{stamp}.sh")
|
||||||
|
open(restore, "w").write(f"""#!/bin/sh
|
||||||
|
# Canonical restore for {os.path.abspath(args.source)}
|
||||||
|
# Generated {meta['taken_at']} from backup {os.path.basename(dest)}
|
||||||
|
#
|
||||||
|
# STOP EVERY WRITER FIRST. Restoring under a live Core corrupts both.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TARGET='{os.path.abspath(args.source)}'
|
||||||
|
BACKUP='{os.path.abspath(dest)}'
|
||||||
|
|
||||||
|
test "$(sha256sum "$BACKUP" | cut -d' ' -f1)" = '{digest}' \\
|
||||||
|
|| {{ echo 'FATAL: backup checksum mismatch, refusing to restore'; exit 1; }}
|
||||||
|
|
||||||
|
# The stale -wal/-shm MUST go, or SQLite replays them over the restored file.
|
||||||
|
rm -f "$TARGET-wal" "$TARGET-shm"
|
||||||
|
cp "$BACKUP" "$TARGET"
|
||||||
|
chmod u+w "$TARGET"
|
||||||
|
|
||||||
|
sqlite3 "$TARGET" 'PRAGMA integrity_check;' 'PRAGMA foreign_key_check;' \\
|
||||||
|
'SELECT MAX(version) FROM _sqlx_migrations;'
|
||||||
|
echo 'restore complete -- now start the PREVIOUS Core and host binaries'
|
||||||
|
""")
|
||||||
|
os.chmod(restore, 0o755)
|
||||||
|
|
||||||
|
print(f"\nbackup : {dest}")
|
||||||
|
print(f" sha256 : {digest}")
|
||||||
|
print(f" size : {meta['backup_size']:,}")
|
||||||
|
print(f" integrity : {dst_info['integrity_check']} fk_violations={dst_info['foreign_key_check']}")
|
||||||
|
print(f" migration : {dst_info['migration_max']} owned={dst_info['owned_cards']} coins={dst_info['coins']:,}")
|
||||||
|
print(f"metadata : {meta_path}")
|
||||||
|
print(f"restore : {restore}")
|
||||||
|
print("\nverification:")
|
||||||
|
for k, v in checks.items():
|
||||||
|
print(f" [{'OK ' if v else 'FAIL'}] {k}")
|
||||||
|
ok = all(checks.values())
|
||||||
|
print("\nRESULT:", "BACKUP VERIFIED" if ok else "BACKUP FAILED VERIFICATION -- DO NOT PROCEED")
|
||||||
|
raise SystemExit(0 if ok else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""READ-ONLY state capture of an OpenFUT Core SQLite DB, for promotion gating.
|
||||||
|
|
||||||
|
Opened `mode=ro` and never written to, so it is safe against live production.
|
||||||
|
Emits a deterministic JSON document: run it before a promotion and again after,
|
||||||
|
then `diff` the two. Every table is counted, so a delta cannot hide in a table
|
||||||
|
nobody thought to list.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
fifa17-promotion-snapshot.py <db-path> [out.json]
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(db_path):
|
||||||
|
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
one = lambda s: con.execute(s).fetchone()[0]
|
||||||
|
|
||||||
|
snap = {
|
||||||
|
"db_path": db_path,
|
||||||
|
"journal_mode": one("PRAGMA journal_mode"),
|
||||||
|
"page_size": one("PRAGMA page_size"),
|
||||||
|
"page_count": one("PRAGMA page_count"),
|
||||||
|
"sqlite_version": sqlite3.sqlite_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Schema version + the full applied-migration ledger. A promotion that
|
||||||
|
# claims "0028 applied" must show it here, with success=1.
|
||||||
|
snap["migration_max"] = one("SELECT MAX(version) FROM _sqlx_migrations")
|
||||||
|
snap["migration_count"] = one("SELECT COUNT(*) FROM _sqlx_migrations")
|
||||||
|
snap["migrations_failed"] = one(
|
||||||
|
"SELECT COUNT(*) FROM _sqlx_migrations WHERE success <> 1")
|
||||||
|
|
||||||
|
# Every table, counted. Deliberately not a hand-picked list.
|
||||||
|
tables = [r[0] for r in con.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||||
|
"AND name NOT LIKE 'sqlite_%' ORDER BY name")]
|
||||||
|
snap["table_counts"] = {t: one(f'SELECT COUNT(*) FROM "{t}"') for t in tables}
|
||||||
|
|
||||||
|
# The economically load-bearing figures, called out so a diff is readable.
|
||||||
|
snap["coins"] = dict(con.execute("SELECT id, coins FROM clubs ORDER BY id").fetchall())
|
||||||
|
snap["owned_total"] = one("SELECT COUNT(*) FROM owned_cards")
|
||||||
|
snap["owned_by_kind"] = dict(
|
||||||
|
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards "
|
||||||
|
"GROUP BY 1 ORDER BY 1").fetchall())
|
||||||
|
snap["squad_players"] = one("SELECT COUNT(*) FROM squad_players")
|
||||||
|
snap["market_listings"] = one("SELECT COUNT(*) FROM market_listings")
|
||||||
|
snap["market_listings_sold"] = one("SELECT COUNT(*) FROM market_listings WHERE sold = 1")
|
||||||
|
snap["market_listings_npc"] = one("SELECT COUNT(*) FROM market_listings WHERE is_npc = 1")
|
||||||
|
snap["game_entity_ext"] = one("SELECT COUNT(*) FROM game_entity_ext")
|
||||||
|
snap["consumable_applications"] = one("SELECT COUNT(*) FROM consumable_applications")
|
||||||
|
snap["packs_total"] = one("SELECT COUNT(*) FROM packs")
|
||||||
|
snap["packs_unopened"] = one("SELECT COUNT(*) FROM packs WHERE opened = 0")
|
||||||
|
snap["match_completions"] = one("SELECT COUNT(*) FROM match_completions")
|
||||||
|
|
||||||
|
# Present only after 0028. Absent => pre-0028 DB, which is itself the signal.
|
||||||
|
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
|
||||||
|
snap["owned_cards_columns"] = cols
|
||||||
|
snap["has_0028"] = "contract_matches" in cols
|
||||||
|
if snap["has_0028"]:
|
||||||
|
snap["contract_matches_set"] = one(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL")
|
||||||
|
snap["contract_matches_sum"] = one(
|
||||||
|
"SELECT COALESCE(SUM(contract_matches), 0) FROM owned_cards")
|
||||||
|
|
||||||
|
# A content fingerprint over ownership: catches a row silently rewritten
|
||||||
|
# even when every count stays identical.
|
||||||
|
h = hashlib.sha256()
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, "
|
||||||
|
"chemistry_style, position_override, training_bonus, content_kind, "
|
||||||
|
"quantity FROM owned_cards ORDER BY id"):
|
||||||
|
h.update(("|".join("" if v is None else str(v) for v in r)).encode())
|
||||||
|
snap["owned_cards_fingerprint_pre0028_columns"] = h.hexdigest()
|
||||||
|
|
||||||
|
snap["integrity_check"] = one("PRAGMA integrity_check")
|
||||||
|
snap["foreign_key_check"] = len(con.execute("PRAGMA foreign_key_check").fetchall())
|
||||||
|
con.close()
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
raise SystemExit(2)
|
||||||
|
snap = snapshot(sys.argv[1])
|
||||||
|
text = json.dumps(snap, indent=2, sort_keys=True)
|
||||||
|
print(text)
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
open(sys.argv[2], "w").write(text + "\n")
|
||||||
Executable
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Report host-level packet interception affecting OpenFUT endpoints.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
---------------
|
||||||
|
During the 2026-08-22 Rust production cutover, four stale `openfut-switch` nft
|
||||||
|
rules were still redirecting production-facing traffic to staging:
|
||||||
|
|
||||||
|
42127 -> :42227 8081 -> :8281 8094 -> :18094 8099 -> :18106
|
||||||
|
|
||||||
|
They matched `ip daddr 10.10.0.120`, so every server-side probe via 127.0.0.1 or
|
||||||
|
the container IP passed while the CLIENT was refused or silently sent to
|
||||||
|
staging. That cost an entire false-negative acceptance round: a retail
|
||||||
|
quick-sell landed on staging while production sat untouched, and the launcher
|
||||||
|
reported "OpenFUT server is not answering".
|
||||||
|
|
||||||
|
The lesson is mechanical, so the check is too: a connectivity gate that only
|
||||||
|
probes loopback proves nothing about what the client reaches.
|
||||||
|
|
||||||
|
READ-ONLY. This tool never deletes a rule. Removing interception is a
|
||||||
|
deliberate operator act (`openfut-switch.sh off --name <id>`).
|
||||||
|
|
||||||
|
Exit status: 0 CLEAN, 1 INTERCEPTION_PRESENT, 2 could not determine.
|
||||||
|
|
||||||
|
python3 scripts/openfut-interception-preflight.py
|
||||||
|
python3 scripts/openfut-interception-preflight.py --advertise 10.10.0.120
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# The endpoints a FIFA 17 client actually dials, plus the staging twins that
|
||||||
|
# stale rules historically pointed at.
|
||||||
|
PRODUCTION_PORTS = {
|
||||||
|
8099: "UTAS (Rust utas-host)",
|
||||||
|
8081: "roster",
|
||||||
|
8094: "POW api",
|
||||||
|
8085: "POW content",
|
||||||
|
4216: "LSX (client-side)",
|
||||||
|
42127: "Blaze redirector",
|
||||||
|
42130: "Blaze main",
|
||||||
|
42131: "Nucleus",
|
||||||
|
}
|
||||||
|
|
||||||
|
# LSX runs on the GAME machine, not here: the compose file publishes 4216 but
|
||||||
|
# `OPENFUT_SERVERS` excludes lsx by default, so "published but not served" is
|
||||||
|
# its normal state and must not be reported as interception. Every other port
|
||||||
|
# above is expected to be served on this host.
|
||||||
|
NOT_SERVED_HERE = {4216}
|
||||||
|
STAGING_PORTS = {8299: "staging UTAS", 42327: "staging redirector",
|
||||||
|
42330: "staging blaze main", 8281: "staging roster",
|
||||||
|
18094: "staging POW", 18106: "retired season-shim"}
|
||||||
|
ALL_PORTS = dict(PRODUCTION_PORTS)
|
||||||
|
ALL_PORTS.update(STAGING_PORTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str]) -> str:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||||
|
return r.stdout
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def switch_status() -> tuple[str, list[str]]:
|
||||||
|
"""`openfut-switch.sh status`, which owns the redirect lifecycle."""
|
||||||
|
for path in ("/home/alex/OpenFUT/openfut-blaze-host/openfut-switch.sh",
|
||||||
|
"openfut-blaze-host/openfut-switch.sh"):
|
||||||
|
if shutil.which("bash") and subprocess.run(
|
||||||
|
["test", "-x", path], capture_output=True).returncode == 0:
|
||||||
|
out = _run([path, "status"])
|
||||||
|
active = [l.strip() for l in out.splitlines()
|
||||||
|
if "->" in l and "openfut-switch" in l]
|
||||||
|
return ("INACTIVE" if "INACTIVE" in out else
|
||||||
|
("ACTIVE" if active else "UNKNOWN")), active
|
||||||
|
return "UNKNOWN", []
|
||||||
|
|
||||||
|
|
||||||
|
def nft_redirects(advertise: str) -> tuple[list[str], list[str]]:
|
||||||
|
"""Split nft rules touching our ports into REDIRECTs (interception) and
|
||||||
|
Docker's own DNAT (legitimate publishing)."""
|
||||||
|
out = _run(["sudo", "-n", "nft", "list", "ruleset"])
|
||||||
|
if not out:
|
||||||
|
out = _run(["nft", "list", "ruleset"])
|
||||||
|
redirects, dnats = [], []
|
||||||
|
port_re = re.compile(r"dport (\d+)")
|
||||||
|
for line in out.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
m = port_re.search(s)
|
||||||
|
if not m or int(m.group(1)) not in ALL_PORTS:
|
||||||
|
continue
|
||||||
|
if "redirect to" in s:
|
||||||
|
redirects.append(s)
|
||||||
|
elif "dnat to" in s:
|
||||||
|
dnats.append(s)
|
||||||
|
return redirects, dnats
|
||||||
|
|
||||||
|
|
||||||
|
def reachable(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||||
|
s = socket.socket()
|
||||||
|
s.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
s.connect((host, port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--advertise", default="10.10.0.120",
|
||||||
|
help="the address the CLIENT dials (not loopback)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
adv = args.advertise
|
||||||
|
|
||||||
|
print("OpenFUT interception preflight — advertise=%s" % adv)
|
||||||
|
print()
|
||||||
|
|
||||||
|
state, active = switch_status()
|
||||||
|
print("openfut-switch : %s" % state)
|
||||||
|
for a in active:
|
||||||
|
print(" %s" % a)
|
||||||
|
|
||||||
|
redirects, dnats = nft_redirects(adv)
|
||||||
|
print("\nnft REDIRECTs on OpenFUT ports : %d%s"
|
||||||
|
% (len(redirects), " <-- INTERCEPTION" if redirects else ""))
|
||||||
|
for r in redirects:
|
||||||
|
print(" %s" % r[:150])
|
||||||
|
print("nft DNAT (docker publishing) : %d (expected, not interception)"
|
||||||
|
% len(dnats))
|
||||||
|
|
||||||
|
# The point of the whole tool: compare loopback with the address the client
|
||||||
|
# actually dials. A redirect keyed on the LAN IP is invisible to loopback.
|
||||||
|
print("\neffective endpoint, loopback vs advertised:")
|
||||||
|
disagree = []
|
||||||
|
for port, label in sorted(PRODUCTION_PORTS.items()):
|
||||||
|
lo = reachable("127.0.0.1", port)
|
||||||
|
wan = reachable(adv, port)
|
||||||
|
if lo == wan:
|
||||||
|
flag = ""
|
||||||
|
elif port in NOT_SERVED_HERE:
|
||||||
|
flag = " (not served here -- expected)"
|
||||||
|
else:
|
||||||
|
flag = " <-- DISAGREE"
|
||||||
|
if lo != wan and port not in NOT_SERVED_HERE:
|
||||||
|
disagree.append((port, label, lo, wan))
|
||||||
|
print(" :%-6d %-24s loopback=%-5s advertised=%-5s%s"
|
||||||
|
% (port, label, lo, wan, flag))
|
||||||
|
|
||||||
|
intercepted = bool(redirects) or state == "ACTIVE" or bool(disagree)
|
||||||
|
print()
|
||||||
|
if intercepted:
|
||||||
|
print("RESULT: INTERCEPTION_PRESENT")
|
||||||
|
if disagree:
|
||||||
|
print(" loopback and the advertised address disagree on: %s"
|
||||||
|
% ", ".join(":%d" % p for p, _, _, _ in disagree))
|
||||||
|
print(" Nothing was changed. To clear a switch rule, run explicitly:")
|
||||||
|
print(" openfut-blaze-host/openfut-switch.sh off --name <id>")
|
||||||
|
return 1
|
||||||
|
print("RESULT: CLEAN")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+35
-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
|
# Two authoritative modern kit-card definitions for one real source team. These
|
||||||
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
|
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
|
||||||
KIT_TEAM_ID = 21
|
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 = [
|
STAGING_KITS = [
|
||||||
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006),
|
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006, 14),
|
||||||
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003),
|
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003, 15),
|
||||||
]
|
]
|
||||||
|
|
||||||
# The remaining club-item families, so the rig exercises EVERY ownable class
|
# 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
|
# 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.
|
# equipped slot, so it is owned as generic `misc` content.
|
||||||
STAGING_CLUB_ITEMS = [
|
STAGING_CLUB_ITEMS = [
|
||||||
# (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id)
|
# (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),
|
# club_asset_id)
|
||||||
("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None),
|
# club_asset_id is the wire `assetId` from club_items.json, family specific
|
||||||
("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None),
|
# and never the carddbid: badge 6000005 -> its teamid 21, ball 8120194 -> 100,
|
||||||
(None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None),
|
# 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
|
# 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:
|
with open(safe_path(lay.catalog)) as fh:
|
||||||
catalog = json.load(fh)
|
catalog = json.load(fh)
|
||||||
existing = {definition["id"] for definition in definitions}
|
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:
|
if card_id not in existing:
|
||||||
definitions.append({
|
definitions.append({
|
||||||
"id": card_id,
|
"id": card_id,
|
||||||
@@ -550,10 +560,11 @@ def materialise(lay: Layout) -> None:
|
|||||||
"kind": "kit",
|
"kind": "kit",
|
||||||
"subtype": 9,
|
"subtype": 9,
|
||||||
"card_asset_id": 35,
|
"card_asset_id": 35,
|
||||||
|
"club_asset_id": club_asset_id,
|
||||||
"team_id": KIT_TEAM_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:
|
if card_id not in existing:
|
||||||
definitions.append({
|
definitions.append({
|
||||||
"id": card_id,
|
"id": card_id,
|
||||||
@@ -580,6 +591,8 @@ def materialise(lay: Layout) -> None:
|
|||||||
"subtype": subtype,
|
"subtype": subtype,
|
||||||
"card_asset_id": art,
|
"card_asset_id": art,
|
||||||
}
|
}
|
||||||
|
if club_asset is not None:
|
||||||
|
entry["club_asset_id"] = club_asset
|
||||||
if team is not None:
|
if team is not None:
|
||||||
entry["team_id"] = team
|
entry["team_id"] = team
|
||||||
catalog["cards"][card_id] = entry
|
catalog["cards"][card_id] = entry
|
||||||
@@ -589,7 +602,14 @@ def materialise(lay: Layout) -> None:
|
|||||||
definitions.append({
|
definitions.append({
|
||||||
"id": mgr["card_id"],
|
"id": mgr["card_id"],
|
||||||
"name": mgr["label"],
|
"name": mgr["label"],
|
||||||
|
# `overall` STAYS 0. It feeds pricing and squad projection, and a
|
||||||
|
# staff card is not a player; the authoritative number lives in
|
||||||
|
# `source_rating` below, which is what the contract-tier rules read.
|
||||||
"overall": 0,
|
"overall": 0,
|
||||||
|
# managercards.value, the same number the catalog carries as
|
||||||
|
# `rating`. Core owns it so the manager-contract tier is resolved
|
||||||
|
# from Core-owned state rather than from adapter projection.
|
||||||
|
"source_rating": mgr["rating"],
|
||||||
"position": "",
|
"position": "",
|
||||||
"nation": "",
|
"nation": "",
|
||||||
"league": "",
|
"league": "",
|
||||||
@@ -694,7 +714,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
|||||||
wanted = set(
|
wanted = set(
|
||||||
[card for _, card in SELLER_SQUAD_CARDS]
|
[card for _, card in SELLER_SQUAD_CARDS]
|
||||||
+ [DISPOSABLE_CARD]
|
+ [DISPOSABLE_CARD]
|
||||||
+ [card for _, _, card, _ in STAGING_KITS]
|
+ [card for _, _, card, _, _ in STAGING_KITS]
|
||||||
+ [STAGING_MANAGER["card_id"]]
|
+ [STAGING_MANAGER["card_id"]]
|
||||||
)
|
)
|
||||||
what = f"all {len(wanted)} fixture seed card ids"
|
what = f"all {len(wanted)} fixture seed card ids"
|
||||||
@@ -705,7 +725,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
|||||||
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
|
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
wanted |= {card for _, _, card, _ in STAGING_KITS}
|
wanted |= {card for _, _, card, _, _ in STAGING_KITS}
|
||||||
wanted.add(STAGING_MANAGER["card_id"])
|
wanted.add(STAGING_MANAGER["card_id"])
|
||||||
what = (f"all {len(wanted)} distinct card ids owned by the real club "
|
what = (f"all {len(wanted)} distinct card ids owned by the real club "
|
||||||
"(plus the kit and manager fixtures)")
|
"(plus the kit and manager fixtures)")
|
||||||
@@ -976,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.
|
# calling a kit a player, and Core is the ownership authority.
|
||||||
owned = [
|
owned = [
|
||||||
(owned_id, seller_club, card_id, "kit", TS)
|
(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(
|
owned.append(
|
||||||
(
|
(
|
||||||
@@ -989,7 +1009,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
|||||||
)
|
)
|
||||||
owned.extend(
|
owned.extend(
|
||||||
(owned_id, seller_club, card_id, kind, TS)
|
(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
|
in STAGING_CLUB_ITEMS
|
||||||
)
|
)
|
||||||
if real_club is None:
|
if real_club is None:
|
||||||
@@ -1003,7 +1023,6 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
|||||||
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
|
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
|
||||||
owned,
|
owned,
|
||||||
)
|
)
|
||||||
|
|
||||||
if real_club is None:
|
if real_club is None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO squads (id, club_id, name, formation, created_at, "
|
"INSERT INTO squads (id, club_id, name, formation, created_at, "
|
||||||
@@ -1027,14 +1046,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
|||||||
"VALUES (?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?)",
|
||||||
[
|
[
|
||||||
(seller_club, f"{slot}_kit", owned_id, TS)
|
(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.
|
# badge / ball / stadium are slot-keyed exactly like the kits.
|
||||||
# A league logo has no slot, so it stays owned-but-unequipped —
|
# A league logo has no slot, so it stays owned-but-unequipped —
|
||||||
# which is itself worth exercising.
|
# which is itself worth exercising.
|
||||||
+ [
|
+ [
|
||||||
(seller_club, slot, owned_id, TS)
|
(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
|
if slot is not None
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# OpenFUT systemd units
|
||||||
|
|
||||||
|
Supervision for OpenFUT Core and the FIFA17 UTAS host. Replaces the previous
|
||||||
|
`setsid nohup … nsenter …` launch, which had no restart policy, no boot
|
||||||
|
persistence and no supervisor-visible logs.
|
||||||
|
|
||||||
|
| file | scope | installed? |
|
||||||
|
|---|---|---|
|
||||||
|
| `openfut-staging-netns.service` | binds the staging anchor netns | **yes** — proving ground |
|
||||||
|
| `openfut-staging-core.service` | staging Core, port 18081 | **yes** |
|
||||||
|
| `openfut-staging-host.service` | staging host, port 8299 | **yes** |
|
||||||
|
| `openfut-staging-netns-reconcile.{service,timer}` | staging netns lifecycle | **yes** |
|
||||||
|
| `openfut-netns.service` | binds the production anchor netns to `/run/netns/openfut` | template only |
|
||||||
|
| `openfut-core.service` | production Core, port 18080 | template only |
|
||||||
|
| `openfut-host.service` | production host, port 8099 | template only |
|
||||||
|
| `openfut-netns-reconcile.{service,timer}` | production netns lifecycle | template only |
|
||||||
|
| `openfut-netns-bind.sh` | resolves the anchor netns by NAME, idempotently, drains stale mounts | helper |
|
||||||
|
| `openfut-netns-reconcile.sh` | keeps services in the anchor's CURRENT netns | helper |
|
||||||
|
| `openfut-wait-tcp.sh` | bounded readiness gate | helper |
|
||||||
|
| `openfut-supervision-install.sh` | install / start / status / uninstall per environment | helper |
|
||||||
|
|
||||||
|
Production templates are **not installed**. Deploy only via the plan in
|
||||||
|
`OpenFUT-Vault/06 Operations/OpenFUT Service Supervision (staging-proven).md`.
|
||||||
|
|
||||||
|
## The three decisions worth knowing
|
||||||
|
|
||||||
|
**1. `Wants=`, not `Requires=`, from host → Core.** Measured on staging:
|
||||||
|
`Requires` propagates a Core stop into a host stop, and a later Core start does
|
||||||
|
*not* bring the host back — a routine Core restart would leave the client with
|
||||||
|
no server. With `Wants`, the host survives a Core outage, answers
|
||||||
|
`503 core_unavailable` (never a Python fallback), and resumes the moment Core
|
||||||
|
returns, with no supervisor intervention.
|
||||||
|
|
||||||
|
**2. Readiness is an `ExecStartPre` TCP gate, not ordering.** `After=`/`Wants=`
|
||||||
|
order units; `Type=exec` only proves the binary exec'd. Neither means Core can
|
||||||
|
serve. Core binds its listener *after* opening the DB, running migrations and
|
||||||
|
loading the content pack, so "port open" is a genuine readiness signal. The gate
|
||||||
|
is bounded and *fails* rather than blocking: a host that waits forever looks
|
||||||
|
healthy to the supervisor while serving nobody.
|
||||||
|
|
||||||
|
**3. The netns is resolved by container NAME at every start.** Production must
|
||||||
|
run inside `openfut-fut-backend`'s network namespace. The container is
|
||||||
|
`restart=unless-stopped`, and its netns inode *changes* on restart — observed
|
||||||
|
`net:[4026539938] → net:[4026540033]`. A hardcoded pid is therefore wrong by
|
||||||
|
construction, and any process left in the old namespace keeps running with no
|
||||||
|
interfaces, silently serving nobody. `openfut-netns-bind.sh` re-resolves by name
|
||||||
|
and refreshes a stale bind mount; `NetworkNamespacePath=` then enters it
|
||||||
|
declaratively.
|
||||||
|
|
||||||
|
**4. Stale namespaces are repaired automatically (this closes the old gap).**
|
||||||
|
Recreating the anchor strands already-running services in the dead namespace,
|
||||||
|
and — the dangerous part — *systemd still reports them `active`*. Measured before
|
||||||
|
the fix: anchor `net:[4026539938] → net:[4026540033]`, Core and host unchanged in
|
||||||
|
the old one, both units `active`, traffic `ConnectionResetError`.
|
||||||
|
|
||||||
|
`openfut-netns-reconcile.sh` on a 10s timer compares the namespace the services
|
||||||
|
are **actually in** against the anchor's **current** one and, only on a real
|
||||||
|
difference, performs one stop → rebind → start cycle. It is level-triggered, so
|
||||||
|
it cannot miss an event and needs no debounce: a burst of three back-to-back
|
||||||
|
recreations produced exactly **one** rebind.
|
||||||
|
|
||||||
|
## Operating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./openfut-supervision-install.sh status staging # units, anchor, netns agreement, mounts
|
||||||
|
journalctl -u openfut-staging-netns-reconcile -f # silent unless it acts
|
||||||
|
sudo systemctl restart openfut-staging-core # host survives and recovers
|
||||||
|
sudo ./openfut-supervision-install.sh install staging # copy + enable (does not start)
|
||||||
|
sudo ./openfut-supervision-install.sh uninstall staging # disable, stop, remove units
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` is the one command worth knowing: it prints each unit's state, the
|
||||||
|
anchor's current netns, the namespace each service is *actually* in with an
|
||||||
|
`ok`/`MISMATCH` verdict, and the mount count (`1` healthy, `>1` a leaked stack).
|
||||||
|
|
||||||
|
Config lives in `EnvironmentFile`s (`…/systemd/core.env`, `host.env`), generated
|
||||||
|
from the live process environment so supervision changed *how* the processes
|
||||||
|
start and nothing about *what* they do. Binaries are immutable copies, so a
|
||||||
|
later `cargo build` cannot change what is running.
|
||||||
|
|
||||||
|
## Interaction with the staging lifecycle script
|
||||||
|
|
||||||
|
`scripts/sold-staging-up.py` still starts its own unsupervised processes and
|
||||||
|
refuses to run while a staging stack is up. Stop the units first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop openfut-staging-host openfut-staging-core
|
||||||
|
python3 scripts/sold-staging-up.py --club real --variant highest …
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconciling the two (having the script drive the units) is deliberately out of
|
||||||
|
scope for the supervision milestone.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT: record unattended proof that supervision recovered after boot
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
After=openfut-host.service openfut-netns-reconcile.timer docker.service
|
||||||
|
Wants=openfut-host.service
|
||||||
|
# Observation only. Deliberately no Requires= and no ordering that anything else
|
||||||
|
# waits on: this unit must never be able to affect the boot it is measuring.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-boot-evidence.sh
|
||||||
|
TimeoutStartSec=300
|
||||||
|
# Never fail the boot over evidence collection.
|
||||||
|
SuccessExitStatus=0 1
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-boot-evidence
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Capture unattended proof that the OpenFUT supervision chain came back by
|
||||||
|
# itself after a boot.
|
||||||
|
#
|
||||||
|
# This exists because the reboot-survival gate is, by definition, a test no
|
||||||
|
# operator can stand inside: the machine under test is the machine running the
|
||||||
|
# session. So the machine records its own recovery.
|
||||||
|
#
|
||||||
|
# It observes only — it never starts, stops or repairs anything. If the chain is
|
||||||
|
# broken, the evidence file says so, which is the point.
|
||||||
|
#
|
||||||
|
# usage: openfut-boot-evidence.sh [outdir]
|
||||||
|
set -u
|
||||||
|
|
||||||
|
OUTDIR="${1:-/home/alex/openfut-promotion/boot-evidence}"
|
||||||
|
ANCHOR=openfut-fut-backend
|
||||||
|
NSNAME=openfut
|
||||||
|
DB=/home/alex/openfut-promotion/state/prod-core.db
|
||||||
|
DEADLINE=180 # seconds to allow for docker + anchor + reconcile to settle
|
||||||
|
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
BOOTID="$(cat /proc/sys/kernel/random/boot_id)"
|
||||||
|
OUT="$OUTDIR/boot-$(date -u +%Y%m%dT%H%M%SZ)-${BOOTID%%-*}.json"
|
||||||
|
|
||||||
|
# Wait until converged, or until the deadline. Converged means both services are
|
||||||
|
# active and share the anchor's CURRENT namespace. Polling rather than a fixed
|
||||||
|
# sleep so a boot-time reconcile retry is captured as "settled late", not as a
|
||||||
|
# failure, and so a healthy boot is recorded promptly.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt "$DEADLINE" ]; do
|
||||||
|
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
|
||||||
|
if [ -n "$cpid" ] && [ "$cpid" != "0" ] && [ -e "/proc/$cpid/ns/net" ]; then
|
||||||
|
want="$(readlink "/proc/$cpid/ns/net")"
|
||||||
|
cm="$(systemctl show -p MainPID --value openfut-core.service 2>/dev/null)"
|
||||||
|
hm="$(systemctl show -p MainPID --value openfut-host.service 2>/dev/null)"
|
||||||
|
if [ -n "$cm" ] && [ "$cm" != "0" ] && [ -e "/proc/$cm/ns/net" ] &&
|
||||||
|
[ -n "$hm" ] && [ "$hm" != "0" ] && [ -e "/proc/$hm/ns/net" ] &&
|
||||||
|
[ "$(readlink "/proc/$cm/ns/net")" = "$want" ] &&
|
||||||
|
[ "$(readlink "/proc/$hm/ns/net")" = "$want" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
i=$((i + 2))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
SETTLED_AFTER="$i"
|
||||||
|
|
||||||
|
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
|
||||||
|
cid="$(docker inspect -f '{{.Id}}' "$ANCHOR" 2>/dev/null || echo none)"
|
||||||
|
ans="$([ "$cpid" != "0" ] && readlink "/proc/$cpid/ns/net" 2>/dev/null || echo none)"
|
||||||
|
cm="$(systemctl show -p MainPID --value openfut-core.service 2>/dev/null)"
|
||||||
|
hm="$(systemctl show -p MainPID --value openfut-host.service 2>/dev/null)"
|
||||||
|
cns="$([ -n "$cm" ] && [ "$cm" != "0" ] && readlink "/proc/$cm/ns/net" 2>/dev/null || echo none)"
|
||||||
|
hns="$([ -n "$hm" ] && [ "$hm" != "0" ] && readlink "/proc/$hm/ns/net" 2>/dev/null || echo none)"
|
||||||
|
mounts="$(awk -v t="/run/netns/$NSNAME" '$2==t {n++} END {print n+0}' /proc/mounts)"
|
||||||
|
|
||||||
|
# Non-mutating reads, from inside the anchor namespace for Core (it is not
|
||||||
|
# published to the host namespace) and on the published port for the host.
|
||||||
|
# One argument only: a second positional would be unbound under `set -u` and
|
||||||
|
# would abort the subshell, silently yielding an empty probe result.
|
||||||
|
probe() { nsenter --net="/proc/$cpid/ns/net" python3 - "$1" <<'PY' 2>/dev/null || echo "ERR"
|
||||||
|
import sys, urllib.request, urllib.error
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(urllib.request.Request(sys.argv[1], headers={"X-OpenFUT-Game": "fifa17", "X-UT-SID": "boot-evidence"}), timeout=8)
|
||||||
|
print(r.status)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(e.code)
|
||||||
|
except Exception:
|
||||||
|
print("ERR")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$cpid" != "0" ]; then
|
||||||
|
core_health="$(probe http://127.0.0.1:18080/health)"
|
||||||
|
core_coll="$(probe 'http://127.0.0.1:18080/collection?limit=1')"
|
||||||
|
host_acct="$(probe http://127.0.0.1:8099/ut/game/fifa17/user/accountinfo)"
|
||||||
|
host_club="$(probe 'http://127.0.0.1:8099/ut/game/fifa17/club?count=1&start=0')"
|
||||||
|
else
|
||||||
|
core_health=none; core_coll=none; host_acct=none; host_club=none
|
||||||
|
fi
|
||||||
|
|
||||||
|
econ="$(python3 - "$DB" <<'PY' 2>/dev/null || echo '{}'
|
||||||
|
import sys, sqlite3, json
|
||||||
|
c = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
|
||||||
|
q = lambda s: c.execute(s).fetchone()[0]
|
||||||
|
print(json.dumps({
|
||||||
|
"schema": q("SELECT MAX(version) FROM _sqlx_migrations"),
|
||||||
|
"coins": q("SELECT coins FROM clubs"),
|
||||||
|
"owned": q("SELECT COUNT(*) FROM owned_cards"),
|
||||||
|
"by_content_kind": dict(c.execute("SELECT COALESCE(content_kind,'(null)'),COUNT(*) FROM owned_cards GROUP BY 1 ORDER BY 1").fetchall()),
|
||||||
|
"applications": q("SELECT COUNT(*) FROM consumable_applications"),
|
||||||
|
"contract_sum": q("SELECT COALESCE(SUM(contract_matches),0) FROM owned_cards"),
|
||||||
|
"squad_players": q("SELECT COUNT(*) FROM squad_players"),
|
||||||
|
"market_listings": q("SELECT COUNT(*) FROM market_listings"),
|
||||||
|
"game_entity_ext": q("SELECT COUNT(*) FROM game_entity_ext"),
|
||||||
|
"integrity": q("PRAGMA integrity_check"),
|
||||||
|
"fk": len(c.execute("PRAGMA foreign_key_check").fetchall()),
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
owner_rust="$(journalctl -u openfut-host -b --no-pager -o cat 2>/dev/null | grep -c 'owner=RUST')"
|
||||||
|
owner_py="$(journalctl -u openfut-host -b --no-pager -o cat 2>/dev/null | grep -c 'owner=PYTHON')"
|
||||||
|
|
||||||
|
cat > "$OUT" <<EOF
|
||||||
|
{
|
||||||
|
"boot_id": "$BOOTID",
|
||||||
|
"captured_utc": "$(date -u +%FT%TZ)",
|
||||||
|
"settled_after_seconds": $SETTLED_AFTER,
|
||||||
|
"uptime_at_capture": "$(cut -d' ' -f1 /proc/uptime)",
|
||||||
|
"anchor": {"container": "${cid%%[!0-9a-f]*}", "pid": "$cpid", "netns": "$ans"},
|
||||||
|
"core": {"pid": "$cm", "netns": "$cns", "active": "$(systemctl is-active openfut-core.service)", "enabled": "$(systemctl is-enabled openfut-core.service)", "nrestarts": "$(systemctl show -p NRestarts --value openfut-core.service)"},
|
||||||
|
"host": {"pid": "$hm", "netns": "$hns", "active": "$(systemctl is-active openfut-host.service)", "enabled": "$(systemctl is-enabled openfut-host.service)", "nrestarts": "$(systemctl show -p NRestarts --value openfut-host.service)"},
|
||||||
|
"netns_unit": {"active": "$(systemctl is-active openfut-netns.service)", "enabled": "$(systemctl is-enabled openfut-netns.service)", "result": "$(systemctl show -p Result --value openfut-netns.service)"},
|
||||||
|
"reconcile": {"timer_active": "$(systemctl is-active openfut-netns-reconcile.timer)", "timer_enabled": "$(systemctl is-enabled openfut-netns-reconcile.timer)", "last_result": "$(systemctl show -p Result --value openfut-netns-reconcile.service)", "acted_this_boot": $(journalctl -u openfut-netns-reconcile -b --no-pager -o cat 2>/dev/null | grep -c 'rebind cycle')},
|
||||||
|
"netns_agreement": $([ "$ans" = "$cns" ] && [ "$ans" = "$hns" ] && [ "$ans" != "none" ] && echo true || echo false),
|
||||||
|
"nsfs_mounts": $mounts,
|
||||||
|
"probes": {"core_health": "$core_health", "core_collection": "$core_coll", "host_accountinfo": "$host_acct", "host_club": "$host_club"},
|
||||||
|
"authority": {"owner_rust": $owner_rust, "owner_python": $owner_py},
|
||||||
|
"economy": $econ
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 0644 "$OUT"
|
||||||
|
ln -sfn "$OUT" "$OUTDIR/latest.json"
|
||||||
|
echo "openfut-boot-evidence: wrote $OUT (settled after ${SETTLED_AFTER}s)"
|
||||||
|
|
||||||
|
# Also drop the ordering proof for this boot, so ordering is read from real
|
||||||
|
# timestamps rather than inferred from unit dependencies.
|
||||||
|
journalctl -b -u openfut-netns -u openfut-core -u openfut-host -u openfut-netns-reconcile \
|
||||||
|
-o short-precise --no-pager > "$OUTDIR/latest-journal.txt" 2>/dev/null
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT Core (PRODUCTION) — authoritative economy state
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
# PRODUCTION TEMPLATE — NOT INSTALLED. Deploy only via the promotion plan in
|
||||||
|
# `06 Operations/OpenFUT Service Supervision (staging-proven).md`.
|
||||||
|
#
|
||||||
|
# Core is the SINGLE WRITER of prod-core.db (verified by lsof: exactly one
|
||||||
|
# process holds it open). Nothing here may be templated into a second instance.
|
||||||
|
After=network-online.target docker.service openfut-netns.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=openfut-netns.service
|
||||||
|
|
||||||
|
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
|
||||||
|
# [Service] (`systemd-analyze verify` flags it), which would have left the
|
||||||
|
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
# root, matching the current production processes exactly. Supervision changes
|
||||||
|
# HOW the process is started, never what it is or what it can reach.
|
||||||
|
User=root
|
||||||
|
|
||||||
|
# The container's netns, published by openfut-netns.service. This replaces the
|
||||||
|
# hand-typed `nsenter --net=/proc/<pid>/ns/net` in the runbook: same namespace,
|
||||||
|
# no hardcoded pid, and re-resolved on every start.
|
||||||
|
NetworkNamespacePath=/run/netns/openfut
|
||||||
|
|
||||||
|
EnvironmentFile=/etc/openfut/core.env
|
||||||
|
|
||||||
|
# An IMMUTABLE promotion artifact, not target/release. A later `cargo build`
|
||||||
|
# must not be able to change what production is running — the same invariant
|
||||||
|
# the promotion process already relies on.
|
||||||
|
ExecStart=/home/alex/openfut-migration/promote-contract-20260822-184409/artifacts/openfut-core
|
||||||
|
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
KillMode=mixed
|
||||||
|
# Generous, so a WAL checkpoint is never SIGKILLed mid-write. Observed shutdown
|
||||||
|
# is sub-second.
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-core
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT FIFA17 UTAS host (PRODUCTION) — client-facing, Core-dependent
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
# PRODUCTION TEMPLATE — NOT INSTALLED.
|
||||||
|
|
||||||
|
Requires=openfut-netns.service
|
||||||
|
After=openfut-netns.service
|
||||||
|
Wants=openfut-core.service
|
||||||
|
After=openfut-core.service
|
||||||
|
|
||||||
|
# `Wants` on Core, deliberately NOT `Requires`/`BindsTo`/`PartOf` — measured on
|
||||||
|
# staging: those propagate a Core stop into a host stop, and a later Core start
|
||||||
|
# does NOT bring the host back, so a routine Core restart would leave the client
|
||||||
|
# with no server at all. With `Wants` the host survives a Core outage, answers
|
||||||
|
# 503 `core_unavailable` (never a Python fallback), and resumes serving the
|
||||||
|
# moment Core returns with no supervisor intervention. Both halves were proven
|
||||||
|
# on the staging units.
|
||||||
|
#
|
||||||
|
# `Requires` on the netns unit IS correct: without the namespace the host would
|
||||||
|
# bind the wrong network entirely.
|
||||||
|
|
||||||
|
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
|
||||||
|
# [Service] (`systemd-analyze verify` flags it), which would have left the
|
||||||
|
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=root
|
||||||
|
NetworkNamespacePath=/run/netns/openfut
|
||||||
|
EnvironmentFile=/etc/openfut/host.env
|
||||||
|
|
||||||
|
# Admission gate. Ordering alone proves nothing about readiness (Type=exec only
|
||||||
|
# proves the binary exec'd). Core binds its port only after migrations and the
|
||||||
|
# content pack have loaded, so "port open" is a real readiness signal here.
|
||||||
|
# Bounded and FAILING rather than blocking: a host that waits forever looks
|
||||||
|
# healthy to the supervisor while serving nobody.
|
||||||
|
ExecStartPre=/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 18080 30
|
||||||
|
|
||||||
|
ExecStart=/home/alex/openfut-migration/promote-contract-20260822-184409/artifacts/openfut-utas-host
|
||||||
|
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-host
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Publish a Docker container's network namespace into /run/netns so systemd
|
||||||
|
# units can enter it declaratively with NetworkNamespacePath=.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. Production Core and host must run inside the
|
||||||
|
# `openfut-fut-backend` container's netns: that is where the published client
|
||||||
|
# ports live and where the Python oracle answers on 127.0.0.1:8199. Today they
|
||||||
|
# get there with `nsenter --net=/proc/<pid>/ns/net`, where <pid> is typed by
|
||||||
|
# hand into a runbook.
|
||||||
|
#
|
||||||
|
# THE HAZARD THIS FIXES, MEASURED NOT ASSUMED. The container runs with
|
||||||
|
# `restart=unless-stopped`. On a container restart its netns inode CHANGES
|
||||||
|
# (observed 2026-08-22: net:[4026539938] -> net:[4026540033]). A hardcoded pid
|
||||||
|
# is then simply wrong, and — worse — any process already inside the old
|
||||||
|
# namespace keeps running in a namespace with no interfaces, silently serving
|
||||||
|
# nobody. Resolving by container NAME at every start removes the hardcoded pid;
|
||||||
|
# the companion watcher unit handles the already-running case by restarting the
|
||||||
|
# stack when the container restarts.
|
||||||
|
#
|
||||||
|
# Idempotent: a stale bind mount is unmounted and re-made, so re-running after a
|
||||||
|
# container restart is the fix, not a second problem.
|
||||||
|
#
|
||||||
|
# usage: openfut-netns-bind.sh <container-name> <netns-name>
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONTAINER="${1:?container name}"
|
||||||
|
NSNAME="${2:?netns name}"
|
||||||
|
TARGET="/run/netns/${NSNAME}"
|
||||||
|
|
||||||
|
CPID="$(docker inspect -f '{{.State.Pid}}' "$CONTAINER" 2>/dev/null || true)"
|
||||||
|
if [ -z "$CPID" ] || [ "$CPID" = "0" ]; then
|
||||||
|
echo "openfut-netns-bind: container '$CONTAINER' is not running (pid='$CPID')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -e "/proc/$CPID/ns/net" ]; then
|
||||||
|
echo "openfut-netns-bind: /proc/$CPID/ns/net does not exist" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WANT="$(readlink "/proc/$CPID/ns/net")"
|
||||||
|
mkdir -p /run/netns
|
||||||
|
|
||||||
|
# Already published and already CURRENT? Then do nothing — re-mounting under a
|
||||||
|
# live service would be gratuitous churn.
|
||||||
|
if mountpoint -q "$TARGET" 2>/dev/null; then
|
||||||
|
HAVE="ns:[$(stat -c %i "$TARGET" 2>/dev/null || echo 0)]"
|
||||||
|
if [ "net:[$(stat -c %i "$TARGET" 2>/dev/null)]" = "$WANT" ]; then
|
||||||
|
echo "openfut-netns-bind: $TARGET already current ($WANT)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "openfut-netns-bind: $TARGET is STALE ($HAVE, want $WANT) — refreshing"
|
||||||
|
# DRAIN, do not just pop. `mount --bind` STACKS: binding over a busy mount
|
||||||
|
# silently leaves the old one underneath, and staging grew two nsfs entries
|
||||||
|
# on the first rebind before this loop existed. Left alone that is one
|
||||||
|
# leaked mount per container recreation, and the buried namespaces are
|
||||||
|
# exactly the dead ones we are trying to get rid of.
|
||||||
|
#
|
||||||
|
# A umount can legitimately fail while a service still holds the old
|
||||||
|
# namespace open; the caller's job is to stop dependants FIRST. If it is
|
||||||
|
# still busy we stack rather than fail — a current top-of-stack mount is
|
||||||
|
# correct, just untidy — and say so.
|
||||||
|
while mountpoint -q "$TARGET" 2>/dev/null; do
|
||||||
|
umount "$TARGET" 2>/dev/null || {
|
||||||
|
echo "openfut-netns-bind: $TARGET still busy; stacking a current mount over it" >&2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -e "$TARGET" ] || touch "$TARGET"
|
||||||
|
mount --bind "/proc/$CPID/ns/net" "$TARGET"
|
||||||
|
echo "openfut-netns-bind: $TARGET -> $CONTAINER pid $CPID $WANT"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT: reconcile services with the anchor container network namespace
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
After=docker.service
|
||||||
|
Wants=docker.service
|
||||||
|
# PRODUCTION TEMPLATE — NOT INSTALLED.
|
||||||
|
#
|
||||||
|
# Deliberately NO Requires=docker.service: a docker outage must be a quiet
|
||||||
|
# retry, not a failed unit. The script decides that itself and exits 0.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Level-triggered. It compares the namespace Core and the host are ACTUALLY in
|
||||||
|
# against the anchor's CURRENT one, so a burst of container events collapses
|
||||||
|
# into at most one rebind per tick and no separate debounce is needed.
|
||||||
|
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-reconcile.sh \
|
||||||
|
openfut-fut-backend openfut \
|
||||||
|
openfut-netns.service \
|
||||||
|
openfut-core.service openfut-host.service
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-netns-reconcile
|
||||||
Executable
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Keep supervised native services inside the CURRENT network namespace of a
|
||||||
|
# Docker anchor container, and stop them when that anchor is gone.
|
||||||
|
#
|
||||||
|
# ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
|
||||||
|
# OpenFUT's Core and host are native binaries that must run inside the anchor
|
||||||
|
# container's netns. Recreating (or merely restarting) that container gives it a
|
||||||
|
# NEW netns; the already-running services stay in the old one. Measured on
|
||||||
|
# staging 2026-08-22:
|
||||||
|
#
|
||||||
|
# anchor 37288a0fe816 net:[4026539938] -> 55541a03b66e net:[4026540033]
|
||||||
|
# core pid 2081773 net:[4026539938] (unchanged, now orphaned)
|
||||||
|
# host pid 2081791 net:[4026539938] (unchanged, now orphaned)
|
||||||
|
# systemd: both "active" traffic: ConnectionResetError
|
||||||
|
#
|
||||||
|
# Both units report HEALTHY while serving nobody. That is the whole problem:
|
||||||
|
# the failure is invisible to the supervisor.
|
||||||
|
#
|
||||||
|
# ── WHY A RECONCILE AND NOT AN EVENT WATCHER ───────────────────────────────
|
||||||
|
# There is no systemd-native edge signal to bind to. Containers do appear as
|
||||||
|
# `docker-<id>.scope` units (cgroup driver is systemd), but the scope NAME
|
||||||
|
# embeds the container ID, and the ID changes on recreate — so there is no
|
||||||
|
# stable unit for BindsTo=. `NetworkNamespacePath` is resolved at unit start
|
||||||
|
# only, and a .path unit on /run/netns/<name> would watch the very file this
|
||||||
|
# script maintains (circular).
|
||||||
|
#
|
||||||
|
# So the trigger is a timer, and the check is LEVEL-triggered: it compares the
|
||||||
|
# namespace the services are ACTUALLY in against the anchor's CURRENT one. That
|
||||||
|
# is strictly more robust than an edge-triggered watcher, which can miss events
|
||||||
|
# while it is itself restarting or while dockerd is down, and it needs no
|
||||||
|
# debounce logic — a burst of container events collapses into at most one
|
||||||
|
# reconcile per tick, because the only question asked is "does the observed
|
||||||
|
# state differ from the desired state right now?".
|
||||||
|
#
|
||||||
|
# The trigger is deliberately separable from the action. If detection latency
|
||||||
|
# ever matters, a `docker events` unit can invoke THIS SAME script; nothing here
|
||||||
|
# would change.
|
||||||
|
#
|
||||||
|
# ── INVARIANTS ─────────────────────────────────────────────────────────────
|
||||||
|
# * A container PID is runtime state and is NEVER persisted. It is resolved from
|
||||||
|
# Docker on every run.
|
||||||
|
# * Identity is the netns inode of /proc/<current-anchor-pid>/ns/net, never the
|
||||||
|
# container name (same name != same namespace) and never a cached value.
|
||||||
|
# * systemd stays the service authority: this script only requests start/stop/
|
||||||
|
# restart, and unit ordering does the sequencing.
|
||||||
|
# * Silence when correct. It logs only when it acts or fails, so a 10s timer
|
||||||
|
# does not fill the journal.
|
||||||
|
#
|
||||||
|
# usage: openfut-netns-reconcile.sh <container> <nsname> <netns-unit> <unit>...
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONTAINER="${1:?container name}"; shift
|
||||||
|
NSNAME="${1:?netns name}"; shift
|
||||||
|
NSUNIT="${1:?netns unit}"; shift
|
||||||
|
[ "$#" -ge 1 ] || { echo "reconcile: at least one dependent unit required" >&2; exit 2; }
|
||||||
|
UNITS="$*"
|
||||||
|
HERE="$(dirname "$0")"
|
||||||
|
|
||||||
|
log() { echo "openfut-netns-reconcile: $*"; }
|
||||||
|
|
||||||
|
# ---- 1. Is Docker even answering? -----------------------------------------
|
||||||
|
# A daemon outage must be a clean, quiet failure that the timer retries, never a
|
||||||
|
# spin and never a destructive action taken on incomplete information.
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
log "docker daemon unavailable — taking NO action, will retry on the next tick"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 2. Resolve the anchor, by name, right now ----------------------------
|
||||||
|
CPID="$(docker inspect -f '{{.State.Pid}}' "$CONTAINER" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [ -z "$CPID" ] || [ "$CPID" = "0" ] || [ ! -e "/proc/$CPID/ns/net" ]; then
|
||||||
|
# Anchor gone. Services must NOT keep pretending to be healthy inside a
|
||||||
|
# namespace whose owner has died — and must never fall back to host
|
||||||
|
# networking. Stop them; a later tick starts them again once the anchor is
|
||||||
|
# back, which is what makes recovery automatic.
|
||||||
|
RUNNING=""
|
||||||
|
for u in $UNITS; do
|
||||||
|
[ "$(systemctl is-active "$u" 2>/dev/null)" = "active" ] && RUNNING="$RUNNING $u"
|
||||||
|
done
|
||||||
|
if [ -n "$RUNNING" ]; then
|
||||||
|
log "anchor '$CONTAINER' is ABSENT — stopping$RUNNING (no host-network fallback)"
|
||||||
|
# Reverse order: dependants before the thing they depend on.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl stop $RUNNING || true
|
||||||
|
systemctl stop "$NSUNIT" || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
WANT="$(readlink "/proc/$CPID/ns/net")"
|
||||||
|
|
||||||
|
# ---- 3. Compare against where the services ACTUALLY are -------------------
|
||||||
|
# Observed state, not a remembered value: this self-heals no matter how the
|
||||||
|
# drift happened (container recreate, restart, manual nsenter, anything).
|
||||||
|
NEED_ACTION=0
|
||||||
|
REASON=""
|
||||||
|
for u in $UNITS; do
|
||||||
|
state="$(systemctl is-active "$u" 2>/dev/null || true)"
|
||||||
|
if [ "$state" != "active" ]; then
|
||||||
|
NEED_ACTION=1; REASON="$REASON $u=$state"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)"
|
||||||
|
if [ -z "$mp" ] || [ "$mp" = "0" ] || [ ! -e "/proc/$mp/ns/net" ]; then
|
||||||
|
NEED_ACTION=1; REASON="$REASON $u=nopid"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
have="$(readlink "/proc/$mp/ns/net")"
|
||||||
|
if [ "$have" != "$WANT" ]; then
|
||||||
|
NEED_ACTION=1; REASON="$REASON $u=$have"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$NEED_ACTION" = "0" ]; then
|
||||||
|
exit 0 # correct and silent
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 4. One controlled rebind cycle ---------------------------------------
|
||||||
|
log "anchor '$CONTAINER' pid=$CPID ns=$WANT; drift:$REASON"
|
||||||
|
log "namespace changed or services adrift — requesting one rebind cycle"
|
||||||
|
|
||||||
|
# STOP FIRST, then rebind, then start — not restart-around-a-rebind. While a
|
||||||
|
# service is still running it holds the OLD namespace open, the umount fails
|
||||||
|
# busy, and `mount --bind` silently STACKS a second nsfs entry over it. Measured:
|
||||||
|
# the first rebind left two mounts on the path. Stopping the dependants releases
|
||||||
|
# the old namespace so the drain actually succeeds.
|
||||||
|
#
|
||||||
|
# Reverse order on the way down (dependants before their dependency), forward on
|
||||||
|
# the way up — and the way up is systemd's job: `start` honours the units' own
|
||||||
|
# After=/Requires=, so Core is listening before the host's readiness gate runs.
|
||||||
|
REV=""
|
||||||
|
for u in $UNITS; do REV="$u $REV"; done
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl stop $REV || true
|
||||||
|
|
||||||
|
"$HERE/openfut-netns-bind.sh" "$CONTAINER" "$NSNAME"
|
||||||
|
systemctl restart "$NSUNIT"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl start $UNITS
|
||||||
|
|
||||||
|
for u in $UNITS; do
|
||||||
|
mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)"
|
||||||
|
now="$( [ "$mp" != "0" ] && readlink "/proc/$mp/ns/net" || echo '-')"
|
||||||
|
log "rebound $u pid=$mp ns=$now"
|
||||||
|
done
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT: periodic anchor-namespace reconcile
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
# PRODUCTION TEMPLATE — NOT INSTALLED.
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# 10s. Anchor recreation is rare and already an outage; converging inside ~10s
|
||||||
|
# is ample. Measured cost per tick: one `docker inspect` plus two readlinks.
|
||||||
|
OnBootSec=15s
|
||||||
|
OnUnitInactiveSec=10s
|
||||||
|
AccuracySec=1s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT: publish the FIFA17 container network namespace to /run/netns
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/openfut-netns-bind.sh
|
||||||
|
# PRODUCTION TEMPLATE — NOT INSTALLED. Staging runs in the host netns and needs
|
||||||
|
# none of this; only production enters the `openfut-fut-backend` container's
|
||||||
|
# namespace.
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
# Re-resolves the container by NAME on every start, so no pid is ever hardcoded
|
||||||
|
# and a container restart is repaired by restarting this unit. It exits non-zero
|
||||||
|
# when the container is not running, which is what makes Core's Requires= on it
|
||||||
|
# a real admission gate rather than decoration.
|
||||||
|
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-bind.sh openfut-fut-backend openfut
|
||||||
|
|
||||||
|
# Deliberately NO ExecStop unmount. Tearing the bind mount down while Core and
|
||||||
|
# the host are still inside that namespace would strand them; the namespace is
|
||||||
|
# owned by the container's lifetime, not by this unit's.
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-netns
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Roll the production supervision promotion back to the incumbent detached pair.
|
||||||
|
#
|
||||||
|
# This restores the process-management arrangement that ran production before
|
||||||
|
# the supervision cutover: two nohup'd binaries entered into the anchor
|
||||||
|
# container's network namespace via nsenter, parented to init.
|
||||||
|
#
|
||||||
|
# It performs NO database or schema change, because the promotion it reverses
|
||||||
|
# performs none either. The economy is never at risk here.
|
||||||
|
#
|
||||||
|
# Run with --dry-run first; that prints the exact commands and touches nothing.
|
||||||
|
#
|
||||||
|
# usage: openfut-rollback-detached.sh [--dry-run]
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DRY=0
|
||||||
|
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||||||
|
|
||||||
|
ANCHOR=openfut-fut-backend
|
||||||
|
ART=/home/alex/openfut-migration/promote-contract-20260822-184409/artifacts
|
||||||
|
ENVJSON=/home/alex/openfut-migration/promote-contract-20260822-184409/env.json
|
||||||
|
LOGDIR=/home/alex/openfut-promotion/logs
|
||||||
|
|
||||||
|
run() {
|
||||||
|
if [ "$DRY" -eq 1 ]; then printf ' DRY %s\n' "$*"; else printf ' RUN %s\n' "$*"; sh -c "$*"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== 1. stop and remove supervision (units only; binaries, env and DB untouched) =="
|
||||||
|
run "systemctl disable --now openfut-host.service openfut-core.service openfut-netns.service openfut-netns-reconcile.timer || true"
|
||||||
|
run "/home/alex/OpenFUT/scripts/systemd/openfut-supervision-install.sh uninstall production"
|
||||||
|
|
||||||
|
echo "== 2. resolve the anchor's CURRENT namespace (never a remembered pid) =="
|
||||||
|
if [ "$DRY" -eq 1 ]; then
|
||||||
|
APID="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo '<anchor pid>')"
|
||||||
|
echo " DRY anchor $ANCHOR pid=$APID ns=$(readlink "/proc/$APID/ns/net" 2>/dev/null || echo '<ns>')"
|
||||||
|
else
|
||||||
|
APID="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR")"
|
||||||
|
[ -n "$APID" ] && [ "$APID" != "0" ] || { echo "anchor $ANCHOR is not running — cannot roll back into its namespace" >&2; exit 1; }
|
||||||
|
echo " anchor $ANCHOR pid=$APID ns=$(readlink "/proc/$APID/ns/net")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The environment is replayed from the captured live process environment, never
|
||||||
|
# retyped. jq renders it as KEY=VALUE pairs for env(1).
|
||||||
|
CORE_ENV="$(jq -r '.core | to_entries[] | "\(.key)=\(.value)"' "$ENVJSON" | tr '\n' ' ')"
|
||||||
|
HOST_ENV="$(jq -r '.host | to_entries[] | "\(.key)=\(.value)"' "$ENVJSON" | tr '\n' ' ')"
|
||||||
|
|
||||||
|
echo "== 3. relaunch incumbent Core (the writer first) =="
|
||||||
|
run "mkdir -p $LOGDIR"
|
||||||
|
run "nsenter --net=/proc/$APID/ns/net env $CORE_ENV nohup $ART/openfut-core >> $LOGDIR/prod-core.log 2>&1 &"
|
||||||
|
run "/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 18080 30"
|
||||||
|
|
||||||
|
echo "== 4. relaunch incumbent host =="
|
||||||
|
run "nsenter --net=/proc/$APID/ns/net env $HOST_ENV nohup $ART/openfut-utas-host >> $LOGDIR/prod-host.log 2>&1 &"
|
||||||
|
run "/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 8099 30"
|
||||||
|
|
||||||
|
echo "== 5. verify =="
|
||||||
|
run "pgrep -af 'openfut-core|openfut-utas-host'"
|
||||||
|
echo "Rollback complete. No database or schema change was made."
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT Core (STAGING) — authoritative economy state
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
# Staging Core is the write authority for the staging SQLite DB. Nothing else
|
||||||
|
# may hold it open, which is why there is no second instance and no oneshot
|
||||||
|
# migration unit: Core runs its own migrations at startup, before it binds.
|
||||||
|
After=network-online.target docker.service openfut-staging-netns.service
|
||||||
|
Wants=network-online.target
|
||||||
|
# Requires, not Wants: without the anchor namespace Core would bind the WRONG
|
||||||
|
# network entirely. Absent anchor must mean "do not start", never "start on
|
||||||
|
# the host network".
|
||||||
|
Requires=openfut-staging-netns.service
|
||||||
|
|
||||||
|
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
|
||||||
|
# [Service] (`systemd-analyze verify` flags it), which would have left the
|
||||||
|
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=alex
|
||||||
|
Group=alex
|
||||||
|
WorkingDirectory=/home/alex/openfut-sold-staging
|
||||||
|
|
||||||
|
# Enter the anchor container namespace declaratively. Equivalent to the
|
||||||
|
# `nsenter --net=/proc/<pid>/ns/net` production uses today, but with no pid
|
||||||
|
# baked in: the path is re-resolved by openfut-staging-netns.service.
|
||||||
|
NetworkNamespacePath=/run/netns/openfut-staging
|
||||||
|
|
||||||
|
# Config is DATA, not baked into the unit, so the same unit file promotes to
|
||||||
|
# production with a different EnvironmentFile.
|
||||||
|
EnvironmentFile=/home/alex/openfut-sold-staging/systemd/core.env
|
||||||
|
|
||||||
|
# The binary is an IMMUTABLE COPY taken at staging-up time. A later `cargo
|
||||||
|
# build` cannot silently change what staging is running — the same property the
|
||||||
|
# promotion artifacts rely on.
|
||||||
|
ExecStart=/home/alex/openfut-sold-staging/bin/openfut-core
|
||||||
|
|
||||||
|
# Graceful stop. Both binaries already exit cleanly on SIGTERM (the
|
||||||
|
# restart-persistence harness has always stopped them that way), and 20s is far
|
||||||
|
# more than the observed sub-second shutdown; it exists so a slow WAL
|
||||||
|
# checkpoint is never SIGKILLed mid-write.
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
# Restart WITHOUT a tight loop. `on-failure` deliberately excludes a clean
|
||||||
|
# operator stop. 5s spacing with a 5-in-60s ceiling means a genuinely broken
|
||||||
|
# build lands in `failed` where it is visible, instead of thrashing the DB.
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-staging-core
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT FIFA17 UTAS host (STAGING) — client-facing, Core-dependent
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
|
||||||
|
# ORDERING AND ADMISSION. `Wants` + `After` order the host after Core and pull
|
||||||
|
# Core in when the host is started; neither gives READINESS, because Type=exec
|
||||||
|
# only proves the binary exec'd. The ExecStartPre below is what actually admits
|
||||||
|
# traffic: the host cannot reach "active" while Core is not listening.
|
||||||
|
Wants=openfut-staging-core.service
|
||||||
|
After=openfut-staging-core.service
|
||||||
|
# Requires on the NAMESPACE (hard) but only Wants on Core (soft) — the host
|
||||||
|
# must never bind the host network, yet must survive a Core blip.
|
||||||
|
Requires=openfut-staging-netns.service
|
||||||
|
After=openfut-staging-netns.service
|
||||||
|
|
||||||
|
# `Wants`, deliberately NOT `Requires`/`BindsTo`/`PartOf`. Those propagate a
|
||||||
|
# Core stop into a host stop, and — measured, not assumed — a later Core start
|
||||||
|
# does NOT bring the host back, so a routine Core restart would silently leave
|
||||||
|
# the client with no server at all.
|
||||||
|
#
|
||||||
|
# With `Wants` the host survives a Core outage and answers 503
|
||||||
|
# `core_unavailable`, never falling back to Python. That is the behaviour
|
||||||
|
# production already exhibited on 2026-08-22 when Core was SIGHUP'd out from
|
||||||
|
# under a live host. Because the host holds no Core state between requests, it
|
||||||
|
# resumes serving the moment Core returns, with no supervisor intervention.
|
||||||
|
|
||||||
|
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
|
||||||
|
# [Service] (`systemd-analyze verify` flags it), which would have left the
|
||||||
|
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=alex
|
||||||
|
Group=alex
|
||||||
|
WorkingDirectory=/home/alex/openfut-sold-staging
|
||||||
|
|
||||||
|
NetworkNamespacePath=/run/netns/openfut-staging
|
||||||
|
|
||||||
|
EnvironmentFile=/home/alex/openfut-sold-staging/systemd/host.env
|
||||||
|
|
||||||
|
# Readiness gate. Bounded, and FAILS rather than blocking forever: a host that
|
||||||
|
# waits indefinitely looks healthy to the supervisor while serving nothing.
|
||||||
|
ExecStartPre=/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 18081 30
|
||||||
|
|
||||||
|
ExecStart=/home/alex/openfut-sold-staging/bin/openfut-utas-host
|
||||||
|
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-staging-host
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT STAGING: reconcile services with the anchor container network namespace
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
After=docker.service
|
||||||
|
Wants=docker.service
|
||||||
|
|
||||||
|
# Deliberately NO Requires=docker.service: a docker outage must be a quiet
|
||||||
|
# retry, not a failed unit. The script itself decides that and exits 0.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Level-triggered: compares the namespace the services are ACTUALLY in against
|
||||||
|
# the anchor's CURRENT one, so a burst of container events collapses into at
|
||||||
|
# most one rebind per tick and no separate debounce is needed.
|
||||||
|
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-reconcile.sh \
|
||||||
|
openfut-staging-anchor openfut-staging \
|
||||||
|
openfut-staging-netns.service \
|
||||||
|
openfut-staging-core.service openfut-staging-host.service
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-staging-netns-reconcile
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT STAGING: periodic anchor-namespace reconcile
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# 10s cadence. Container recreation is rare and already an outage; converging
|
||||||
|
# within ~10s is ample, and a level-triggered check at this rate costs one
|
||||||
|
# `docker inspect` plus two readlinks.
|
||||||
|
OnBootSec=15s
|
||||||
|
OnUnitInactiveSec=10s
|
||||||
|
AccuracySec=1s
|
||||||
|
# Not Persistent=: there is nothing to catch up on. The check is stateless and
|
||||||
|
# the next tick after boot is authoritative.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OpenFUT STAGING: publish the anchor container network namespace to /run/netns/openfut-staging
|
||||||
|
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
# Re-resolved on every start, so this unit is also the REPAIR action after the
|
||||||
|
# anchor container is recreated: `systemctl restart openfut-staging-netns` plus
|
||||||
|
# a restart of the dependants re-enters the current namespace.
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
# Resolves the anchor BY NAME, never by a stored pid, and exits non-zero when
|
||||||
|
# the anchor is absent — which is what makes the dependants' Requires= a real
|
||||||
|
# admission gate rather than decoration.
|
||||||
|
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-bind.sh openfut-staging-anchor openfut-staging
|
||||||
|
|
||||||
|
# No ExecStop unmount: the namespace belongs to the container's lifetime, and
|
||||||
|
# tearing the bind mount down under a live Core/host would strand them.
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=openfut-staging-netns
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Install or remove the OpenFUT systemd supervision set for one environment.
|
||||||
|
#
|
||||||
|
# Environments are symmetric on purpose: staging and production differ only in
|
||||||
|
# unit prefix, anchor container and EnvironmentFile location, so what staging
|
||||||
|
# proved is what production gets.
|
||||||
|
#
|
||||||
|
# install copy units, daemon-reload, enable (does NOT start)
|
||||||
|
# start start in dependency order and report the namespace agreement
|
||||||
|
# status one-screen health: units, anchor, netns agreement, mounts
|
||||||
|
# uninstall disable + stop + remove units (leaves binaries, DB and env alone)
|
||||||
|
#
|
||||||
|
# The install step deliberately does NOT start anything: on production the
|
||||||
|
# changeover has to be sequenced against retiring the existing detached
|
||||||
|
# processes, which is an operator decision, not a script's.
|
||||||
|
#
|
||||||
|
# usage: openfut-supervision-install.sh <install|start|status|uninstall> <staging|production>
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ACTION="${1:?install|start|status|uninstall}"
|
||||||
|
ENVNAME="${2:?staging|production}"
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
case "$ENVNAME" in
|
||||||
|
staging)
|
||||||
|
PREFIX="openfut-staging"
|
||||||
|
ANCHOR="openfut-staging-anchor"
|
||||||
|
NSNAME="openfut-staging"
|
||||||
|
UNITS="openfut-staging-netns.service openfut-staging-core.service openfut-staging-host.service openfut-staging-netns-reconcile.service openfut-staging-netns-reconcile.timer"
|
||||||
|
ENABLE="openfut-staging-netns.service openfut-staging-core.service openfut-staging-host.service openfut-staging-netns-reconcile.timer"
|
||||||
|
;;
|
||||||
|
production)
|
||||||
|
PREFIX="openfut"
|
||||||
|
ANCHOR="openfut-fut-backend"
|
||||||
|
NSNAME="openfut"
|
||||||
|
UNITS="openfut-netns.service openfut-core.service openfut-host.service openfut-netns-reconcile.service openfut-netns-reconcile.timer"
|
||||||
|
ENABLE="openfut-netns.service openfut-core.service openfut-host.service openfut-netns-reconcile.timer"
|
||||||
|
;;
|
||||||
|
*) echo "unknown environment '$ENVNAME'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
install)
|
||||||
|
for u in $UNITS; do
|
||||||
|
[ -f "$HERE/$u" ] || { echo "missing unit $HERE/$u" >&2; exit 1; }
|
||||||
|
install -m 0644 "$HERE/$u" "/etc/systemd/system/$u"
|
||||||
|
echo "installed /etc/systemd/system/$u"
|
||||||
|
done
|
||||||
|
systemctl daemon-reload
|
||||||
|
for u in $UNITS; do systemd-analyze verify "/etc/systemd/system/$u" || true; done
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl enable $ENABLE
|
||||||
|
echo "enabled (NOT started — start explicitly once the old processes are retired)"
|
||||||
|
;;
|
||||||
|
|
||||||
|
start)
|
||||||
|
systemctl start "${PREFIX}-netns.service"
|
||||||
|
systemctl start "${PREFIX}-core.service"
|
||||||
|
systemctl start "${PREFIX}-host.service"
|
||||||
|
systemctl start "${PREFIX}-netns-reconcile.timer"
|
||||||
|
sleep 3
|
||||||
|
"$0" status "$ENVNAME"
|
||||||
|
;;
|
||||||
|
|
||||||
|
status)
|
||||||
|
printf '%-42s %s\n' "unit" "state"
|
||||||
|
for u in $UNITS; do printf ' %-40s %s\n' "$u" "$(systemctl is-active "$u" 2>/dev/null || true)"; done
|
||||||
|
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
|
||||||
|
if [ "$cpid" != "0" ] && [ -e "/proc/$cpid/ns/net" ]; then
|
||||||
|
want="$(readlink "/proc/$cpid/ns/net")"
|
||||||
|
else
|
||||||
|
want="(anchor absent)"
|
||||||
|
fi
|
||||||
|
echo " anchor $ANCHOR pid=$cpid ns=$want"
|
||||||
|
for u in "${PREFIX}-core.service" "${PREFIX}-host.service"; do
|
||||||
|
mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)"
|
||||||
|
ns="-"; [ "$mp" != "0" ] && [ -e "/proc/$mp/ns/net" ] && ns="$(readlink "/proc/$mp/ns/net")"
|
||||||
|
match="MISMATCH"; [ "$ns" = "$want" ] && match="ok"
|
||||||
|
printf ' %-40s pid=%-8s ns=%-18s %s\n' "$u" "$mp" "$ns" "$match"
|
||||||
|
done
|
||||||
|
# Exact mount-point match. A substring grep for "openfut" also counts
|
||||||
|
# "openfut-staging" and reports a phantom leaked stack on production —
|
||||||
|
# a monitoring lie of exactly the kind this tooling exists to remove.
|
||||||
|
mounts="$(awk -v t="/run/netns/$NSNAME" '$2==t {n++} END {print n+0}' /proc/mounts)"
|
||||||
|
echo " netns mounts on /run/netns/$NSNAME: $mounts (1 = healthy, >1 = leaked stack)"
|
||||||
|
;;
|
||||||
|
|
||||||
|
uninstall)
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl disable --now $ENABLE 2>/dev/null || true
|
||||||
|
systemctl stop "${PREFIX}-netns-reconcile.service" 2>/dev/null || true
|
||||||
|
for u in $UNITS; do rm -f "/etc/systemd/system/$u"; echo "removed /etc/systemd/system/$u"; done
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl reset-failed 2>/dev/null || true
|
||||||
|
# The bind mount is intentionally left: the namespace belongs to the
|
||||||
|
# container, and tearing it down is not part of removing supervision.
|
||||||
|
echo "uninstalled. Binaries, EnvironmentFiles, /run/netns and the database are untouched."
|
||||||
|
;;
|
||||||
|
|
||||||
|
*) echo "unknown action '$ACTION'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user