Compare commits
37 Commits
e48d659bce
...
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 |
@@ -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
|
||||||
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
+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}")
|
||||||
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())
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Hardware-only trace of the engine-local overwrite wrapper entry.
|
||||||
|
|
||||||
|
Breaks before the prologue of FUN_147ce47e0, where [rsp] is the exact direct
|
||||||
|
caller return address and R8D is the team ID later written to the final match
|
||||||
|
record. This closes the one frame Wine PE unwinding could not recover.
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
WRAPPER_VA = 0x147CE47E0
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
self.index = 0
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.index += 1
|
||||||
|
thread = _thread()
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": thread,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
class WrapperBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State):
|
||||||
|
self.state = state
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{WRAPPER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
stack = _reg("rsp")
|
||||||
|
caller_return = _u64(stack)
|
||||||
|
self.state.log(
|
||||||
|
"engine_overwrite_wrapper_entry",
|
||||||
|
wrapper_va=WRAPPER_VA,
|
||||||
|
caller_return_address=caller_return,
|
||||||
|
source_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
side_argument=_reg("rdx") & 0xFFFFFFFF,
|
||||||
|
registers=_registers(),
|
||||||
|
caller_disassembly=(
|
||||||
|
gdb.execute(f"x/12i 0x{caller_return - 32:x}", to_string=True)
|
||||||
|
if caller_return else None
|
||||||
|
),
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error", where="engine_overwrite_wrapper", error=str(exc),
|
||||||
|
traceback=traceback.format_exc()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, _cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
breakpoint = WrapperBreakpoint(_STATE)
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={"engine_overwrite_wrapper": {"number": breakpoint.number, "va": WRAPPER_VA}},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""GDB payload for the LIVE-PROVEN engine match-team +0x14 writer.
|
||||||
|
|
||||||
|
READ-ONLY hardware debug only:
|
||||||
|
|
||||||
|
0x147c652ce mov dword [rdx + rcx + 0x44], r8d
|
||||||
|
|
||||||
|
At the first team-like source value, derives both fixed-stride record fields
|
||||||
|
from live RCX and arms 4-byte WRITE watchpoints on:
|
||||||
|
|
||||||
|
teamId A = rcx + 0x44
|
||||||
|
teamId B = rcx + 0x44 + 0x45c
|
||||||
|
|
||||||
|
The execute breakpoint records the intended source value before every call. The
|
||||||
|
watchpoints then capture both the expected write and any later overwrite, even
|
||||||
|
if the overwrite comes from a different function.
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
WRITER_VA = 0x147C652CE
|
||||||
|
POST_WRITER_VA = 0x147C652D3
|
||||||
|
SIDE_STRIDE = 0x45C
|
||||||
|
TEAM_FIELD_OFF = 0x44
|
||||||
|
RECORD_FIELD_OFF = 0x14
|
||||||
|
TEAM_LIKE = {73, 240, 241, 243, 130000, 130001}
|
||||||
|
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, log_path: str):
|
||||||
|
self.log_path = log_path
|
||||||
|
self.event_index = 0
|
||||||
|
self.engine_base = None
|
||||||
|
self.watch_a = None
|
||||||
|
self.watch_b = None
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.event_index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.event_index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def arm_fields(self, engine_base: int):
|
||||||
|
if self.engine_base == engine_base and self.watch_a and self.watch_b:
|
||||||
|
return
|
||||||
|
for watchpoint in (self.watch_a, self.watch_b):
|
||||||
|
if watchpoint is not None:
|
||||||
|
try:
|
||||||
|
watchpoint.delete()
|
||||||
|
except gdb.error:
|
||||||
|
pass
|
||||||
|
self.engine_base = engine_base
|
||||||
|
self.watch_a = TeamFieldWatchpoint(self, 0, engine_base + TEAM_FIELD_OFF)
|
||||||
|
self.watch_b = TeamFieldWatchpoint(
|
||||||
|
self, 1, engine_base + TEAM_FIELD_OFF + SIDE_STRIDE
|
||||||
|
)
|
||||||
|
self.log(
|
||||||
|
"team_field_watchpoints_armed",
|
||||||
|
engine_base=engine_base,
|
||||||
|
team_id_a_address=self.watch_a.address,
|
||||||
|
team_id_b_address=self.watch_b.address,
|
||||||
|
watchpoint_a=self.watch_a.number,
|
||||||
|
watchpoint_b=self.watch_b.number,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamFieldWatchpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, side: int, address: int):
|
||||||
|
self.state = state
|
||||||
|
self.side = side
|
||||||
|
self.address = address
|
||||||
|
super().__init__(
|
||||||
|
f"*(int*)0x{address:x}",
|
||||||
|
type=gdb.BP_WATCHPOINT,
|
||||||
|
wp_class=gdb.WP_WRITE,
|
||||||
|
internal=False,
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
pc = _reg("rip")
|
||||||
|
writer = WRITER_VA if pc == POST_WRITER_VA else None
|
||||||
|
record_start = self.address - RECORD_FIELD_OFF
|
||||||
|
record = _read(record_start, 0x7C)
|
||||||
|
self.state.log(
|
||||||
|
"final_team_field_write_post",
|
||||||
|
side=self.side,
|
||||||
|
watch_address=self.address,
|
||||||
|
value=_i32(self.address),
|
||||||
|
stopped_pc=pc,
|
||||||
|
writer_va=writer,
|
||||||
|
record_start=record_start,
|
||||||
|
record_hex=record.hex() if record else None,
|
||||||
|
registers=_registers(),
|
||||||
|
disassembly=gdb.execute("x/12i $pc-32", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="team_field_watchpoint",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class FinalWriterBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State):
|
||||||
|
self.state = state
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{WRITER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
engine_base = _reg("rcx")
|
||||||
|
side_offset = _reg("rdx")
|
||||||
|
source_value = _reg("r8") & 0xFFFFFFFF
|
||||||
|
if source_value in TEAM_LIKE:
|
||||||
|
self.state.arm_fields(engine_base)
|
||||||
|
destination = engine_base + side_offset + TEAM_FIELD_OFF
|
||||||
|
side = side_offset // SIDE_STRIDE if side_offset in (0, SIDE_STRIDE) else None
|
||||||
|
self.state.log(
|
||||||
|
"final_writer_pre",
|
||||||
|
instruction_va=WRITER_VA,
|
||||||
|
engine_base=engine_base,
|
||||||
|
side_offset=side_offset,
|
||||||
|
side=side,
|
||||||
|
destination=destination,
|
||||||
|
record_start=destination - RECORD_FIELD_OFF,
|
||||||
|
source_register="r8d",
|
||||||
|
source_value=source_value,
|
||||||
|
prior_value=_i32(destination),
|
||||||
|
team_id_a_address=engine_base + TEAM_FIELD_OFF,
|
||||||
|
team_id_b_address=engine_base + TEAM_FIELD_OFF + SIDE_STRIDE,
|
||||||
|
team_id_a_before=_i32(engine_base + TEAM_FIELD_OFF),
|
||||||
|
team_id_b_before=_i32(engine_base + TEAM_FIELD_OFF + SIDE_STRIDE),
|
||||||
|
registers=_registers(),
|
||||||
|
disassembly=gdb.execute("x/6i $pc", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="final_writer",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, _cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
writer = FinalWriterBreakpoint(_STATE)
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={
|
||||||
|
"final_writer": {"number": writer.number, "va": WRITER_VA},
|
||||||
|
},
|
||||||
|
side_stride=SIDE_STRIDE,
|
||||||
|
team_field_offset=TEAM_FIELD_OFF,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Hardware-only origin trace for the exact SetTeam team context.
|
||||||
|
|
||||||
|
Matches the typed integer context pointer selected by SetTeam to the constructor
|
||||||
|
invocation that produced it. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CONTEXT_REUSE = 0x1477C17FC
|
||||||
|
CONTEXT_ALLOCATED = 0x1477C18C1
|
||||||
|
SET_TEAM_STUB = 0x147060A80
|
||||||
|
LOCKED_SETTER_RETURN = 0x1477C2415
|
||||||
|
CONTEXT_STACK_COUNT = 0x144BCEDA0
|
||||||
|
CONTEXT_STACK_ARRAY = 0x144BCEDA8
|
||||||
|
INTERESTING = {73, 130000, 130001}
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name):
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address, size):
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address):
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address):
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread():
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
self.index = 0
|
||||||
|
self.total_constructor_hits = 0
|
||||||
|
self.interesting_constructor_hits = 0
|
||||||
|
self.pending_allocations = {}
|
||||||
|
self.origins = deque(maxlen=4096)
|
||||||
|
|
||||||
|
def log(self, kind, **payload):
|
||||||
|
self.index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def thread_key(self):
|
||||||
|
return tuple(_thread().get("ptid", ()))
|
||||||
|
|
||||||
|
def remember_origin(self, context, origin):
|
||||||
|
if context:
|
||||||
|
self.origins.append({**origin, "context": context})
|
||||||
|
|
||||||
|
def find_origin(self, context):
|
||||||
|
return next((origin for origin in reversed(self.origins)
|
||||||
|
if origin["context"] == context), None)
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state, address):
|
||||||
|
self.state = state
|
||||||
|
self.address = address
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
|
||||||
|
class ContextReuseBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
self.state.total_constructor_hits += 1
|
||||||
|
try:
|
||||||
|
value = _reg("rcx") & 0xFFFFFFFF
|
||||||
|
if value not in INTERESTING:
|
||||||
|
return False
|
||||||
|
self.state.interesting_constructor_hits += 1
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
direct_return = _u64(rsp + 0x28)
|
||||||
|
origin = {
|
||||||
|
"value": value,
|
||||||
|
"direct_return_address": direct_return,
|
||||||
|
"upstream_return_address": (
|
||||||
|
_u64(rsp + 0x68)
|
||||||
|
if direct_return == LOCKED_SETTER_RETURN
|
||||||
|
else direct_return
|
||||||
|
),
|
||||||
|
"constructor_stack_hex": (_read(rsp, 0x100) or b"").hex(),
|
||||||
|
"constructor_hit": self.state.total_constructor_hits,
|
||||||
|
}
|
||||||
|
context = _reg("rax")
|
||||||
|
if context:
|
||||||
|
self.state.remember_origin(context, origin)
|
||||||
|
else:
|
||||||
|
self.state.pending_allocations[self.state.thread_key()] = origin
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="context_reuse",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ContextAllocatedBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
origin = self.state.pending_allocations.pop(self.state.thread_key(), None)
|
||||||
|
if origin is not None:
|
||||||
|
self.state.remember_origin(_reg("rdx"), origin)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="context_allocated",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SetTeamStubBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
count = _i32(CONTEXT_STACK_COUNT)
|
||||||
|
array = _u64(CONTEXT_STACK_ARRAY)
|
||||||
|
team_context = (
|
||||||
|
_u64(array + (count - 2) * 8)
|
||||||
|
if array and count is not None and count >= 2
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
side_context = (
|
||||||
|
_u64(array + (count - 1) * 8)
|
||||||
|
if array and count is not None and count >= 1
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
self.state.log(
|
||||||
|
"set_team_stub_entry",
|
||||||
|
context_stack_count=count,
|
||||||
|
team_context=team_context,
|
||||||
|
team_context_hex=(_read(team_context, 0x40) or b"").hex(),
|
||||||
|
team_value=_i32(team_context + 0x10) if team_context else None,
|
||||||
|
side_context=side_context,
|
||||||
|
side_value=_i32(side_context + 0x10) if side_context else None,
|
||||||
|
matched_origin=self.state.find_origin(team_context),
|
||||||
|
caller_return_address=_u64(rsp),
|
||||||
|
entry_registers={
|
||||||
|
name: _reg(name)
|
||||||
|
for name in ("rcx", "rdx", "r8", "r9")
|
||||||
|
},
|
||||||
|
backtrace=gdb.execute("bt 32", to_string=True),
|
||||||
|
total_constructor_hits=self.state.total_constructor_hits,
|
||||||
|
interesting_constructor_hits=self.state.interesting_constructor_hits,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error",
|
||||||
|
where="set_team_stub",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log(
|
||||||
|
"inferior_exited",
|
||||||
|
detail=str(event),
|
||||||
|
total_constructor_hits=_STATE.total_constructor_hits,
|
||||||
|
interesting_constructor_hits=_STATE.interesting_constructor_hits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path, _cards_base):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
points = {
|
||||||
|
"context_reuse": ContextReuseBreakpoint(_STATE, CONTEXT_REUSE),
|
||||||
|
"context_allocated": ContextAllocatedBreakpoint(_STATE, CONTEXT_ALLOCATED),
|
||||||
|
"set_team_stub": SetTeamStubBreakpoint(_STATE, SET_TEAM_STUB),
|
||||||
|
}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={
|
||||||
|
name: {"number": point.number, "va": point.address}
|
||||||
|
for name, point in points.items()
|
||||||
|
},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
matching="exact_context_pointer",
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Hardware-only trace of engine game-setup context selection.
|
||||||
|
|
||||||
|
Captures the function that requests team/side, selector indices 1/0, selected
|
||||||
|
transient context objects, and the typed value getter. No client writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
DISPATCH = 0x147060D00
|
||||||
|
SELECT_VALUE = 0x147572C50
|
||||||
|
CONTEXT_SELECTED = 0x1477C845D
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _printable_pointers(address: int, data: bytes) -> dict:
|
||||||
|
found = {}
|
||||||
|
for offset in range(0, len(data) - 7, 8):
|
||||||
|
pointer = struct.unpack_from("<Q", data, offset)[0]
|
||||||
|
raw = _read(pointer, 128)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
value = raw.split(b"\0", 1)[0]
|
||||||
|
try:
|
||||||
|
text = value.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
if len(text) >= 3 and all(char.isprintable() for char in text):
|
||||||
|
found[hex(offset)] = {"pointer": pointer, "text": text}
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
self.index = 0
|
||||||
|
self.requested_indices = {}
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.index += 1
|
||||||
|
event = {"event": kind, "event_index": self.index, "time_unix": time.time(),
|
||||||
|
"thread": _thread(), **payload}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush(); os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return tuple(_thread().get("ptid", ()))
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int):
|
||||||
|
self.state = state
|
||||||
|
self.address = address
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
caller = _u64(rsp)
|
||||||
|
self.state.log(
|
||||||
|
"game_setup_dispatch_entry",
|
||||||
|
caller_return_address=caller,
|
||||||
|
caller_disassembly=(gdb.execute(f"x/12i 0x{caller-32:x}", to_string=True)
|
||||||
|
if caller else None),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="dispatch", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SelectValueBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
index = _reg("rcx") & 0xFFFFFFFF
|
||||||
|
self.state.requested_indices[self.state.key()] = index
|
||||||
|
self.state.log("context_value_request", index=index)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="select_value", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ContextSelectedBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
index = _reg("rdi") & 0xFFFFFFFF
|
||||||
|
context = _reg("rbx")
|
||||||
|
data = _read(context, 0x80) or b""
|
||||||
|
self.state.log(
|
||||||
|
"context_selected",
|
||||||
|
requested_index=self.state.requested_indices.get(self.state.key()),
|
||||||
|
selector_index=index,
|
||||||
|
context=context,
|
||||||
|
type_flags=_i32(context + 8),
|
||||||
|
value_i32=_i32(context + 0x10),
|
||||||
|
value_qword=_u64(context + 0x10),
|
||||||
|
context_hex=data.hex(),
|
||||||
|
printable_pointers=_printable_pointers(context, data),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="context_selected", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, _cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path)
|
||||||
|
points = {
|
||||||
|
"dispatch": DispatchBreakpoint(_STATE, DISPATCH),
|
||||||
|
"select_value": SelectValueBreakpoint(_STATE, SELECT_VALUE),
|
||||||
|
"context_selected": ContextSelectedBreakpoint(_STATE, CONTEXT_SELECTED),
|
||||||
|
}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={name: {"number": bp.number, "va": bp.address} for name, bp in points.items()},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Hardware-only trace of CardsGameSetupAdapter query 13 and overwrite input.
|
||||||
|
|
||||||
|
Breakpoints:
|
||||||
|
|
||||||
|
FUN_180031340 entry incoming teamId/side/context
|
||||||
|
0x18003148f pre-call query id, selector, output/count pointers
|
||||||
|
0x180031495 post-call complete 48-byte records and count
|
||||||
|
0x180031861 submit original incoming teamId sent to engine
|
||||||
|
|
||||||
|
This proves whether query 13 influences the overwrite. No INT3/software
|
||||||
|
breakpoints, client writes, or game input.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
ENTRY = 0x180031340
|
||||||
|
QUERY_PRE = 0x18003148F
|
||||||
|
QUERY_POST = 0x180031495
|
||||||
|
SUBMIT = 0x180031861
|
||||||
|
MAX_RECORDS = 100
|
||||||
|
RECORD_SIZE = 48
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0 or size < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
def _printable_pointer(pointer: int) -> str | None:
|
||||||
|
data = _read(pointer, 96)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
raw = data.split(b"\0", 1)[0]
|
||||||
|
if len(raw) < 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
return text if all(char.isprintable() for char in text) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_record(data: bytes, address: int) -> dict:
|
||||||
|
words = list(struct.unpack("<12i", data))
|
||||||
|
qwords = list(struct.unpack("<6Q", data))
|
||||||
|
strings = {}
|
||||||
|
for index, pointer in enumerate(qwords):
|
||||||
|
text = _printable_pointer(pointer)
|
||||||
|
if text:
|
||||||
|
strings[f"qword_{index}"] = {"pointer": pointer, "text": text}
|
||||||
|
interesting = {
|
||||||
|
str(value): [index * 4 for index, word in enumerate(words) if word == value]
|
||||||
|
for value in (73, 240, 241, 243, 130000, 130001)
|
||||||
|
if value in words
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"address": address,
|
||||||
|
"hex": data.hex(),
|
||||||
|
"i32": words,
|
||||||
|
"u32": [value & 0xFFFFFFFF for value in words],
|
||||||
|
"f32": list(struct.unpack("<12f", data)),
|
||||||
|
"qwords": qwords,
|
||||||
|
"strings": strings,
|
||||||
|
"interesting_values": interesting,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path: str, cards_base: int):
|
||||||
|
self.path = path
|
||||||
|
self.cards_base = cards_base
|
||||||
|
self.index = 0
|
||||||
|
self.calls = {}
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
def thread_key(self):
|
||||||
|
return tuple(_thread().get("ptid", ()))
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, image_va: int):
|
||||||
|
self.state = state
|
||||||
|
self.image_va = image_va
|
||||||
|
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
|
||||||
|
class EntryBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
self.state.log(
|
||||||
|
"game_setup_entry",
|
||||||
|
incoming_context=_reg("rcx"),
|
||||||
|
incoming_side=_reg("rdx") & 0xFFFFFFFF,
|
||||||
|
incoming_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
incoming_r9=_reg("r9"),
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="entry", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPreBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
adapter = _reg("rcx")
|
||||||
|
vtable = _u64(adapter)
|
||||||
|
count_pointer = _u64(rsp + 0x20)
|
||||||
|
state = {
|
||||||
|
"adapter": adapter,
|
||||||
|
"adapter_vtable": vtable,
|
||||||
|
"query_target": _u64(vtable + 0xE0) if vtable else None,
|
||||||
|
"query_id": _reg("rdx") & 0xFFFFFFFF,
|
||||||
|
"selector": _reg("r8") & 0xFFFFFFFF,
|
||||||
|
"output_buffer": _reg("r9"),
|
||||||
|
"count_pointer": count_pointer,
|
||||||
|
"sixth_argument": _u64(rsp + 0x28),
|
||||||
|
"count_before": _i32(count_pointer) if count_pointer else None,
|
||||||
|
"saved_incoming_team_id": _i32(rsp + 0x34),
|
||||||
|
"saved_side": _i32(rsp + 0x50),
|
||||||
|
"saved_engine_context": _u64(rsp + 0x68),
|
||||||
|
"adapter_prefix_hex": (_read(adapter, 0x100) or b"").hex(),
|
||||||
|
}
|
||||||
|
self.state.calls[self.state.thread_key()] = state
|
||||||
|
self.state.log(
|
||||||
|
"query13_pre",
|
||||||
|
**state,
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="query_pre", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPostBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
state = self.state.calls.get(self.state.thread_key(), {})
|
||||||
|
count_pointer = state.get("count_pointer")
|
||||||
|
output = state.get("output_buffer")
|
||||||
|
count = _i32(count_pointer) if count_pointer else None
|
||||||
|
safe_count = min(max(count or 0, 0), MAX_RECORDS)
|
||||||
|
records = []
|
||||||
|
for index in range(safe_count):
|
||||||
|
address = output + index * RECORD_SIZE
|
||||||
|
data = _read(address, RECORD_SIZE)
|
||||||
|
if data and len(data) == RECORD_SIZE:
|
||||||
|
records.append(_decode_record(data, address))
|
||||||
|
self.state.log(
|
||||||
|
"query13_post",
|
||||||
|
query_state=state,
|
||||||
|
count_after=count,
|
||||||
|
records=records,
|
||||||
|
saved_incoming_team_id_after=_i32(_reg("rsp") + 0x34),
|
||||||
|
saved_side_after=_i32(_reg("rsp") + 0x50),
|
||||||
|
registers=_registers(),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="query_post", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitBreakpoint(HardwareBreakpoint):
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
rsp = _reg("rsp")
|
||||||
|
self.state.log(
|
||||||
|
"game_setup_submit",
|
||||||
|
submitted_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
submitted_side=_reg("rdx") & 0xFFFFFFFF,
|
||||||
|
engine_context=_reg("rcx"),
|
||||||
|
saved_incoming_team_id=_i32(rsp + 0x34),
|
||||||
|
saved_side=_i32(rsp + 0x50),
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="submit", error=str(exc), traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path, cards_base)
|
||||||
|
points = {
|
||||||
|
"entry": EntryBreakpoint(_STATE, ENTRY),
|
||||||
|
"query_pre": QueryPreBreakpoint(_STATE, QUERY_PRE),
|
||||||
|
"query_post": QueryPostBreakpoint(_STATE, QUERY_POST),
|
||||||
|
"submit": SubmitBreakpoint(_STATE, SUBMIT),
|
||||||
|
}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints={name: {"number": bp.number, "image_va": bp.image_va} for name, bp in points.items()},
|
||||||
|
record_size=RECORD_SIZE,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""GDB Python payload for read-only FIFA17 match-team writer tracing.
|
||||||
|
|
||||||
|
Loaded by trace_match_team_writer.py. Uses hardware execute breakpoints and a
|
||||||
|
4-byte hardware WRITE watchpoint only; never inserts INT3 and never writes game
|
||||||
|
memory.
|
||||||
|
|
||||||
|
Breakpoints (CardsDLL image VAs):
|
||||||
|
|
||||||
|
* FUN_1800fc500 entry -- derives output pair from RDX and arms *(int*)(rdx+4).
|
||||||
|
* 0x1800fc595 -- pre-write opponent lookup into pair[1].
|
||||||
|
* 0x1800fc5b8 -- mirrored pre-write opponent lookup into pair[0].
|
||||||
|
|
||||||
|
The dynamic watchpoint catches the exact write establishing pair[1], whether it
|
||||||
|
is the opponent lookup at 0x1800fc595 or the own-club store at 0x1800fc5a0.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
ENTRY_RVA = 0x0FC500
|
||||||
|
LOOKUP_TO_TEAM1_RVA = 0x0FC595
|
||||||
|
LOOKUP_TO_TEAM0_RVA = 0x0FC5B8
|
||||||
|
TEAM1_POST_PC_TO_WRITER = {
|
||||||
|
0x1800FC599: 0x1800FC595, # mov [r14+4],ecx
|
||||||
|
0x1800FC5A4: 0x1800FC5A0, # mov [r14+4],eax
|
||||||
|
}
|
||||||
|
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0 or size < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _u8(address: int) -> int | None:
|
||||||
|
data = _read(address, 1)
|
||||||
|
return data[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _u32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<I", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(address: int) -> int | None:
|
||||||
|
data = _read(address, 4)
|
||||||
|
return struct.unpack("<i", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _u64(address: int) -> int | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return struct.unpack("<Q", data)[0] if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _cstring(address: int, maximum: int = 256) -> str | None:
|
||||||
|
data = _read(address, maximum)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return data.split(b"\0", 1)[0].decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _rtti_name(vtable: int, cards_base: int) -> str | None:
|
||||||
|
"""MSVC x64 RTTI name from vtable[-1] CompleteObjectLocator.
|
||||||
|
|
||||||
|
PE RVAs in the locator are module-relative. Failure is evidence-free and is
|
||||||
|
logged as null; no pointer is named from an offset coincidence.
|
||||||
|
"""
|
||||||
|
locator = _u64(vtable - 8) if vtable else None
|
||||||
|
if not locator:
|
||||||
|
return None
|
||||||
|
raw = _read(locator, 24)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
_signature, _offset, _cd_offset, type_rva, _hier_rva, self_rva = struct.unpack(
|
||||||
|
"<IIIiii", raw
|
||||||
|
)
|
||||||
|
if not (0 <= type_rva < 0x10000000 and 0 <= self_rva < 0x10000000):
|
||||||
|
return None
|
||||||
|
image_base = locator - self_rva
|
||||||
|
if abs(image_base - cards_base) > 0x100000:
|
||||||
|
return None
|
||||||
|
return _cstring(image_base + type_rva + 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _object(address: int, cards_base: int) -> dict:
|
||||||
|
vtable = _u64(address) if address else None
|
||||||
|
return {
|
||||||
|
"address": address,
|
||||||
|
"vtable": vtable,
|
||||||
|
"vtable_image_va": (
|
||||||
|
CARDS_IMAGE_BASE + (vtable - cards_base)
|
||||||
|
if vtable and cards_base <= vtable < cards_base + 0x400000
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"rtti": _rtti_name(vtable, cards_base) if vtable else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax",
|
||||||
|
"rbx",
|
||||||
|
"rcx",
|
||||||
|
"rdx",
|
||||||
|
"rsi",
|
||||||
|
"rdi",
|
||||||
|
"rbp",
|
||||||
|
"rsp",
|
||||||
|
"r8",
|
||||||
|
"r9",
|
||||||
|
"r10",
|
||||||
|
"r11",
|
||||||
|
"r12",
|
||||||
|
"r13",
|
||||||
|
"r14",
|
||||||
|
"r15",
|
||||||
|
"rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
def _provenance(state, destination: int | None = None) -> dict:
|
||||||
|
"""Recover the candidate's live input chain without naming the objects."""
|
||||||
|
regs = _registers()
|
||||||
|
context = regs["rbx"]
|
||||||
|
output_pair = regs["r14"]
|
||||||
|
obj = regs["rbp"]
|
||||||
|
nested = _u64(obj + 0xB0) if obj else None
|
||||||
|
field_2e8 = nested + 0x2E8 if nested else None
|
||||||
|
source_base = _u64(field_2e8) if field_2e8 else None
|
||||||
|
participant_holder = regs["r12"]
|
||||||
|
participant = _u64(participant_holder) if participant_holder else None
|
||||||
|
index_70 = _u8(participant + 0x70) if participant else None
|
||||||
|
source_address = (
|
||||||
|
source_base + index_70 * 16
|
||||||
|
if source_base is not None and index_70 is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
source_bytes = _read(source_address, 16) if source_address else None
|
||||||
|
decoded = None
|
||||||
|
if source_bytes and len(source_bytes) == 16:
|
||||||
|
team_id, byte4, byte5, pad, word8, wordc = struct.unpack("<iBBHii", source_bytes)
|
||||||
|
decoded = {
|
||||||
|
"team_id": team_id,
|
||||||
|
"byte_4": byte4,
|
||||||
|
"byte_5": byte5,
|
||||||
|
"pad_6": pad,
|
||||||
|
"word_8": word8,
|
||||||
|
"word_c": wordc,
|
||||||
|
}
|
||||||
|
pair_bytes = _read(output_pair, 8) if output_pair else None
|
||||||
|
return {
|
||||||
|
"destination": destination,
|
||||||
|
"context": _object(context, state.cards_base),
|
||||||
|
"entry_context": _object(state.current_entry.get("context", 0), state.cards_base),
|
||||||
|
"output_pair": output_pair,
|
||||||
|
"entry_output_pair": state.current_entry.get("output_pair"),
|
||||||
|
"output_pair_bytes": pair_bytes.hex() if pair_bytes else None,
|
||||||
|
"output_team_id_0": _i32(output_pair) if output_pair else None,
|
||||||
|
"output_team_id_1": _i32(output_pair + 4) if output_pair else None,
|
||||||
|
"obj": _object(obj, state.cards_base),
|
||||||
|
"nested_at_obj_plus_b0": _object(nested or 0, state.cards_base),
|
||||||
|
"field_plus_2e8_address": field_2e8,
|
||||||
|
"source_array_base": source_base,
|
||||||
|
"participant_holder": participant_holder,
|
||||||
|
"participant": _object(participant or 0, state.cards_base),
|
||||||
|
"participant_plus_70": index_70,
|
||||||
|
"source_record_address": source_address,
|
||||||
|
"source_record_hex": source_bytes.hex() if source_bytes else None,
|
||||||
|
"source_record": decoded,
|
||||||
|
"registers": regs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, log_path: str, cards_base: int):
|
||||||
|
self.log_path = log_path
|
||||||
|
self.cards_base = cards_base
|
||||||
|
self.current_entry: dict = {}
|
||||||
|
self.watchpoint = None
|
||||||
|
self.event_index = 0
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.event_index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.event_index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
class Team1Watchpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int):
|
||||||
|
self.state = state
|
||||||
|
self.address = address
|
||||||
|
super().__init__(
|
||||||
|
f"*(int*)0x{address:x}",
|
||||||
|
type=gdb.BP_WATCHPOINT,
|
||||||
|
wp_class=gdb.WP_WRITE,
|
||||||
|
internal=False,
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
pc = _reg("rip")
|
||||||
|
image_pc = CARDS_IMAGE_BASE + (pc - self.state.cards_base)
|
||||||
|
writer = TEAM1_POST_PC_TO_WRITER.get(image_pc)
|
||||||
|
source_value = None
|
||||||
|
if writer == 0x1800FC595:
|
||||||
|
source_value = _reg("rcx") & 0xFFFFFFFF
|
||||||
|
elif writer == 0x1800FC5A0:
|
||||||
|
source_value = _reg("rax") & 0xFFFFFFFF
|
||||||
|
self.state.log(
|
||||||
|
"team1_write_post",
|
||||||
|
watch_address=self.address,
|
||||||
|
value=_i32(self.address),
|
||||||
|
stopped_pc=pc,
|
||||||
|
stopped_image_va=image_pc,
|
||||||
|
writer_image_va=writer,
|
||||||
|
source_value=source_value,
|
||||||
|
disassembly=gdb.execute("x/10i $pc-32", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
provenance=_provenance(self.state, self.address),
|
||||||
|
)
|
||||||
|
if writer is not None:
|
||||||
|
# The output pair is a short-lived stack buffer. Leaving the
|
||||||
|
# watchpoint active after the candidate's exact write produced
|
||||||
|
# 114k unrelated events when that stack memory was reused.
|
||||||
|
# The two hardware lookup breakpoints remain armed, so disabling
|
||||||
|
# only this completed one-shot watch loses no provenance.
|
||||||
|
self.enabled = False
|
||||||
|
self.state.log(
|
||||||
|
"team1_watchpoint_disabled",
|
||||||
|
watch_address=self.address,
|
||||||
|
reason="candidate exact write captured",
|
||||||
|
)
|
||||||
|
except Exception as exc: # GDB must continue even if evidence rendering fails.
|
||||||
|
self.state.log("trace_error", where="team1_watchpoint", error=str(exc),
|
||||||
|
traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class EntryBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int):
|
||||||
|
self.state = state
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
context, output_pair = _reg("rcx"), _reg("rdx")
|
||||||
|
self.state.current_entry = {
|
||||||
|
"context": context,
|
||||||
|
"output_pair": output_pair,
|
||||||
|
"entry_thread": _thread(),
|
||||||
|
}
|
||||||
|
if self.state.watchpoint is not None:
|
||||||
|
try:
|
||||||
|
self.state.watchpoint.delete()
|
||||||
|
except gdb.error:
|
||||||
|
pass
|
||||||
|
initial = _i32(output_pair + 4)
|
||||||
|
self.state.watchpoint = Team1Watchpoint(self.state, output_pair + 4)
|
||||||
|
self.state.log(
|
||||||
|
"candidate_entry",
|
||||||
|
entry_image_va=0x1800FC500,
|
||||||
|
context=_object(context, self.state.cards_base),
|
||||||
|
output_pair=output_pair,
|
||||||
|
team_id_1_address=output_pair + 4,
|
||||||
|
team_id_1_initial=initial,
|
||||||
|
watchpoint_number=self.state.watchpoint.number,
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
registers=_registers(),
|
||||||
|
)
|
||||||
|
self.state.log(
|
||||||
|
"team1_watchpoint_armed",
|
||||||
|
watch_address=output_pair + 4,
|
||||||
|
watchpoint_number=self.state.watchpoint.number,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="candidate_entry", error=str(exc),
|
||||||
|
traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class LookupStoreBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, address: int, image_va: int, destination_offset: int):
|
||||||
|
self.state = state
|
||||||
|
self.image_va = image_va
|
||||||
|
self.destination_offset = destination_offset
|
||||||
|
super().__init__(
|
||||||
|
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||||
|
)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
destination = _reg("r14") + self.destination_offset
|
||||||
|
self.state.log(
|
||||||
|
"opponent_lookup_store_pre",
|
||||||
|
writer_image_va=self.image_va,
|
||||||
|
destination=destination,
|
||||||
|
destination_offset=self.destination_offset,
|
||||||
|
source_register="ecx",
|
||||||
|
source_value=_reg("rcx") & 0xFFFFFFFF,
|
||||||
|
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
provenance=_provenance(self.state, destination),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log("trace_error", where="lookup_store", error=str(exc),
|
||||||
|
traceback=traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, cards_base: int):
|
||||||
|
"""Called from the supervisor's gdb command file after attach."""
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path, cards_base)
|
||||||
|
entry = EntryBreakpoint(_STATE, cards_base + ENTRY_RVA)
|
||||||
|
lookup_team1 = LookupStoreBreakpoint(
|
||||||
|
_STATE,
|
||||||
|
cards_base + LOOKUP_TO_TEAM1_RVA,
|
||||||
|
0x1800FC595,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
lookup_team0 = LookupStoreBreakpoint(
|
||||||
|
_STATE,
|
||||||
|
cards_base + LOOKUP_TO_TEAM0_RVA,
|
||||||
|
0x1800FC5B8,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
cards_base=cards_base,
|
||||||
|
breakpoints={
|
||||||
|
"candidate_entry": {"number": entry.number, "image_va": 0x1800FC500},
|
||||||
|
"lookup_to_team1": {
|
||||||
|
"number": lookup_team1.number,
|
||||||
|
"image_va": 0x1800FC595,
|
||||||
|
},
|
||||||
|
"lookup_to_team0": {
|
||||||
|
"number": lookup_team0.number,
|
||||||
|
"image_va": 0x1800FC5B8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Hardware-only origin trace for CardsDLL team-pair submissions.
|
||||||
|
|
||||||
|
Distinguishes the three callers of the engine team-id service that can submit a
|
||||||
|
full two-team pair, plus the mode-76 builder that prepares its pair:
|
||||||
|
|
||||||
|
0x1800c7583 correct fixture pair control
|
||||||
|
0x1800c6c23 generic pair submitter
|
||||||
|
0x1800c8dc1 mode-76 pair submitter
|
||||||
|
0x1800c8bf0 mode-76 pair builder entry
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import gdb
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
SITES = {
|
||||||
|
0x1800C7583: ("fixture_pair_submit", "r14", "rsi"),
|
||||||
|
0x1800C6C23: ("generic_pair_submit", "r14", "rsi"),
|
||||||
|
0x1800C8DC1: ("mode76_pair_submit", "r15", "rbp"),
|
||||||
|
}
|
||||||
|
MODE76_BUILDER = 0x1800C8BF0
|
||||||
|
_STATE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _reg(name: str) -> int:
|
||||||
|
return int(gdb.parse_and_eval(f"${name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _thread() -> dict:
|
||||||
|
thread = gdb.selected_thread()
|
||||||
|
if thread is None:
|
||||||
|
return {}
|
||||||
|
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||||
|
|
||||||
|
|
||||||
|
def _read(address: int, size: int) -> bytes | None:
|
||||||
|
if not address or address < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||||
|
except gdb.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pair(address: int) -> list[int] | None:
|
||||||
|
data = _read(address, 8)
|
||||||
|
return list(struct.unpack("<2i", data)) if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def _registers() -> dict:
|
||||||
|
names = (
|
||||||
|
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||||
|
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||||
|
)
|
||||||
|
return {name: _reg(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, log_path: str, cards_base: int):
|
||||||
|
self.log_path = log_path
|
||||||
|
self.cards_base = cards_base
|
||||||
|
self.event_index = 0
|
||||||
|
|
||||||
|
def log(self, kind: str, **payload):
|
||||||
|
self.event_index += 1
|
||||||
|
event = {
|
||||||
|
"event": kind,
|
||||||
|
"event_index": self.event_index,
|
||||||
|
"time_unix": time.time(),
|
||||||
|
"thread": _thread(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
class PairSubmitBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State, image_va: int, name: str, pointer_reg: str, index_reg: str):
|
||||||
|
self.state = state
|
||||||
|
self.image_va = image_va
|
||||||
|
self.name = name
|
||||||
|
self.pointer_reg = pointer_reg
|
||||||
|
self.index_reg = index_reg
|
||||||
|
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
pointer = _reg(self.pointer_reg)
|
||||||
|
index = _reg(self.index_reg) & 0xFFFFFFFF
|
||||||
|
pair_base = pointer - index * 4
|
||||||
|
self.state.log(
|
||||||
|
self.name,
|
||||||
|
instruction_image_va=self.image_va,
|
||||||
|
source_value=_reg("r8") & 0xFFFFFFFF,
|
||||||
|
side=_reg("rdx") & 0xFF,
|
||||||
|
engine_base=_reg("rcx"),
|
||||||
|
pair_pointer=pointer,
|
||||||
|
pair_index=index,
|
||||||
|
pair_base=pair_base,
|
||||||
|
pair=_pair(pair_base),
|
||||||
|
registers=_registers(),
|
||||||
|
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error", where=self.name, error=str(exc),
|
||||||
|
traceback=traceback.format_exc()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Mode76BuilderBreakpoint(gdb.Breakpoint):
|
||||||
|
def __init__(self, state: State):
|
||||||
|
self.state = state
|
||||||
|
address = state.cards_base + (MODE76_BUILDER - CARDS_IMAGE_BASE)
|
||||||
|
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||||
|
self.silent = True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
self.state.log(
|
||||||
|
"mode76_builder_entry",
|
||||||
|
instruction_image_va=MODE76_BUILDER,
|
||||||
|
object=_reg("rcx"),
|
||||||
|
registers=_registers(),
|
||||||
|
backtrace=gdb.execute("bt 24", to_string=True),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.state.log(
|
||||||
|
"trace_error", where="mode76_builder", error=str(exc),
|
||||||
|
traceback=traceback.format_exc()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(event):
|
||||||
|
if _STATE is not None:
|
||||||
|
_STATE.log("inferior_exited", detail=str(event))
|
||||||
|
|
||||||
|
|
||||||
|
def start_trace(log_path: str, cards_base: int):
|
||||||
|
global _STATE
|
||||||
|
open(log_path, "w", encoding="utf-8").close()
|
||||||
|
_STATE = State(log_path, cards_base)
|
||||||
|
breakpoints = {}
|
||||||
|
for image_va, (name, pointer_reg, index_reg) in SITES.items():
|
||||||
|
bp = PairSubmitBreakpoint(_STATE, image_va, name, pointer_reg, index_reg)
|
||||||
|
breakpoints[name] = {"number": bp.number, "image_va": image_va}
|
||||||
|
builder = Mode76BuilderBreakpoint(_STATE)
|
||||||
|
breakpoints["mode76_builder"] = {"number": builder.number, "image_va": MODE76_BUILDER}
|
||||||
|
gdb.events.exited.connect(_on_exit)
|
||||||
|
_STATE.log(
|
||||||
|
"trace_armed",
|
||||||
|
breakpoints=breakpoints,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find IMMEDIATE stores of a constant to a struct offset, in a live module.
|
||||||
|
|
||||||
|
immstore.py <imm_dec> [disp_hex|any] [--exe]
|
||||||
|
|
||||||
|
Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a
|
||||||
|
field; `89 /r` merely propagates one. Emits image VAs so they can be fed to
|
||||||
|
ldis.py. Read-only.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
EXE_IMG = 0x140000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise SystemExit("FIFA17.exe not running")
|
||||||
|
|
||||||
|
|
||||||
|
P = pid()
|
||||||
|
|
||||||
|
|
||||||
|
def module_base(n):
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
if n.lower() in l.lower():
|
||||||
|
return int(l.split("-")[0], 16)
|
||||||
|
raise SystemExit(f"{n} not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def text_spans(base):
|
||||||
|
out = []
|
||||||
|
started = False
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||||
|
if lo == base:
|
||||||
|
started = True
|
||||||
|
continue
|
||||||
|
if started:
|
||||||
|
if not path.strip() and "x" in perms:
|
||||||
|
out.append((lo, hi))
|
||||||
|
elif out:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
args = [a for a in sys.argv[1:] if a != "--exe"]
|
||||||
|
exe = "--exe" in sys.argv
|
||||||
|
imm = int(args[0], 0)
|
||||||
|
want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16)
|
||||||
|
img = EXE_IMG if exe else CARDS_IMG
|
||||||
|
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||||
|
|
||||||
|
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||||
|
immb = struct.pack("<i", imm)
|
||||||
|
hits = 0
|
||||||
|
for lo, hi in text_spans(base):
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
img_lo = img + (lo - base)
|
||||||
|
i = buf.find(b"\xc7", 0)
|
||||||
|
while i >= 0:
|
||||||
|
modrm = buf[i + 1] if i + 1 < len(buf) else 0
|
||||||
|
if (modrm & 0x38) == 0: # /0
|
||||||
|
mod, rm = modrm >> 6, modrm & 7
|
||||||
|
if mod == 1 and i + 7 <= len(buf): # disp8
|
||||||
|
disp, ib = buf[i + 2], i + 3
|
||||||
|
sz = 7
|
||||||
|
elif mod == 2 and i + 10 <= len(buf): # disp32
|
||||||
|
disp, ib = struct.unpack_from("<i", buf, i + 2)[0], i + 6
|
||||||
|
sz = 10
|
||||||
|
elif mod == 0 and rm not in (4, 5) and i + 6 <= len(buf):
|
||||||
|
disp, ib = 0, i + 2
|
||||||
|
sz = 6
|
||||||
|
else:
|
||||||
|
disp = None
|
||||||
|
if disp is not None and buf[ib:ib + 4] == immb:
|
||||||
|
if want_disp is None or disp == want_disp:
|
||||||
|
print(f" image 0x{img_lo+i:x} mov dword [reg+0x{disp:x}], {imm} ({sz}B)")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(b"\xc7", i + 1)
|
||||||
|
print(f" {hits} immediate store(s) of {imm}"
|
||||||
|
+ (f" at +0x{want_disp:x}" if want_disp is not None else ""))
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only live disassembler for the FIFA17 client (CardsDLL / FIFA17.exe).
|
||||||
|
|
||||||
|
ldis.py <image_va_hex> [nbytes] [--exe] disassemble
|
||||||
|
ldis.py --bytes <image_va_hex> [nbytes] hexdump
|
||||||
|
ldis.py --map show module bases
|
||||||
|
|
||||||
|
CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000.
|
||||||
|
Live address = module_base + (image_va - img_base). Sections map 1:1 for both,
|
||||||
|
but this is recomputed and printed so the offset trap stays visible.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
PID = None
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
EXE_IMG = 0x140000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
global PID
|
||||||
|
if PID is None:
|
||||||
|
import glob, os
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
PID = int(os.path.basename(d))
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if PID is None:
|
||||||
|
raise SystemExit("FIFA17.exe not running")
|
||||||
|
return PID
|
||||||
|
|
||||||
|
|
||||||
|
def module_base(needle):
|
||||||
|
"""Base = the NAMED PE-header mapping for the module (Wine maps the rest
|
||||||
|
anonymously, so never trust the mapping that merely CONTAINS an address)."""
|
||||||
|
for l in open(f"/proc/{pid()}/maps"):
|
||||||
|
if needle.lower() in l.lower():
|
||||||
|
return int(l.split("-")[0], 16)
|
||||||
|
raise SystemExit(f"module {needle} not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def live(va, exe=False):
|
||||||
|
if exe:
|
||||||
|
return module_base("FIFA17.exe") + (va - EXE_IMG)
|
||||||
|
return module_base("CardsDLL") + (va - CARDS_IMG)
|
||||||
|
|
||||||
|
|
||||||
|
def read(va, n, exe=False):
|
||||||
|
la = live(va, exe)
|
||||||
|
with open(f"/proc/{pid()}/mem", "rb", 0) as m:
|
||||||
|
m.seek(la)
|
||||||
|
return la, m.read(n)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = sys.argv[1:]
|
||||||
|
if not a or a[0] == "--map":
|
||||||
|
print(f" pid = {pid()}")
|
||||||
|
print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})")
|
||||||
|
print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})")
|
||||||
|
return
|
||||||
|
hexdump = a[0] == "--bytes"
|
||||||
|
if hexdump:
|
||||||
|
a = a[1:]
|
||||||
|
exe = "--exe" in a
|
||||||
|
a = [x for x in a if x != "--exe"]
|
||||||
|
va = int(a[0], 16)
|
||||||
|
n = int(a[1]) if len(a) > 1 else 160
|
||||||
|
la, buf = read(va, n, exe)
|
||||||
|
print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)")
|
||||||
|
if hexdump:
|
||||||
|
for i in range(0, len(buf), 16):
|
||||||
|
c = buf[i:i + 16]
|
||||||
|
print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} "
|
||||||
|
+ "".join(chr(b) if 32 <= b < 127 else "." for b in c))
|
||||||
|
return
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
|
||||||
|
f.write(buf)
|
||||||
|
f.flush()
|
||||||
|
out = subprocess.run(
|
||||||
|
["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel",
|
||||||
|
f"--adjust-vma=0x{va:x}", f.name],
|
||||||
|
capture_output=True, text=True).stdout
|
||||||
|
for line in out.splitlines():
|
||||||
|
if re.match(r"\s+[0-9a-f]+:", line):
|
||||||
|
print(" " + line.strip())
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only census of FIFA 17's RESIDENT club-item vector.
|
||||||
|
|
||||||
|
Chain, every link from CardsDLL static RE:
|
||||||
|
[CardsDLL+0x2e6398] -> owner object (FUN_18011a830)
|
||||||
|
owner->vtable[0x4e8] -> getter returning mgr (call *0x4e8(%rdx))
|
||||||
|
mgr+0x108 .. mgr+0x110 -> club-item vector, stride 24
|
||||||
|
element+0x10 -> the item record pointer (FUN_1800d73d0)
|
||||||
|
record+0x4c cardtype (derived from cardsubtypeid by FUN_1800d8330: 9/10/11 -> 7)
|
||||||
|
record+0x50 cardsubtypeid
|
||||||
|
record+0x5c itemState (101 activeHomeKit, 102 activeAwayKit)
|
||||||
|
record+0x60 category (clone driver FUN_1801c3480 requires 4)
|
||||||
|
record+0x94 teamid
|
||||||
|
record+0xba teamkittypetechid (u16)
|
||||||
|
Offsets not in that list are labelled UNVERIFIED and only dumped raw.
|
||||||
|
No writes. Ever.
|
||||||
|
"""
|
||||||
|
import re, struct, sys, collections
|
||||||
|
|
||||||
|
PID = int(sys.argv[1]) if len(sys.argv) > 1 else 44405
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
|
||||||
|
def rd(a, n):
|
||||||
|
mem.seek(a); return mem.read(n)
|
||||||
|
def q(a):
|
||||||
|
return struct.unpack("<Q", rd(a, 8))[0]
|
||||||
|
def i32(b, o):
|
||||||
|
return struct.unpack_from("<i", b, o)[0]
|
||||||
|
|
||||||
|
# locate CardsDLL by its NEAREST PRECEDING NAMED mapping (Wine maps PE sections anon)
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m: named.append((int(m.group(1),16), m.group(3).strip()))
|
||||||
|
named.sort()
|
||||||
|
base = None
|
||||||
|
for s, p in named:
|
||||||
|
if p.endswith("CardsDLL_Win64_retail.dll"):
|
||||||
|
base = s; break
|
||||||
|
if base is None:
|
||||||
|
print(" CardsDLL mapping not found"); sys.exit(1)
|
||||||
|
print(f" CardsDLL base = {base:#x}")
|
||||||
|
def live(static): return base + (static - 0x180000000)
|
||||||
|
|
||||||
|
# sanity: the 0x7575 sender immediate must be where static RE says
|
||||||
|
probe = rd(live(0x180026fea), 6)
|
||||||
|
print(f" sanity @0x180026fea: {probe.hex(' ')} (expect ba 75 75 00 00)")
|
||||||
|
if probe[:5] != bytes.fromhex("ba75750000"):
|
||||||
|
print(" SANITY FAILED - base wrong, aborting"); sys.exit(1)
|
||||||
|
|
||||||
|
owner = q(live(0x1802e6398))
|
||||||
|
print(f" owner object = {owner:#x}")
|
||||||
|
vt = q(owner)
|
||||||
|
getter = q(vt + 0x4e8)
|
||||||
|
print(f" vtable = {vt:#x}")
|
||||||
|
print(f" vtable[0x4e8] = {getter:#x} bytes: {rd(getter,12).hex(' ')}")
|
||||||
|
# expect: mov rax,[rcx+off] ; ret -> 48 8b 81 off32 c3 or 48 8b 41 off8 c3
|
||||||
|
b = rd(getter, 12)
|
||||||
|
mgr = None
|
||||||
|
if b[0:3] == bytes.fromhex("488d81"):
|
||||||
|
off = struct.unpack_from("<I", b, 3)[0]; mgr = owner + off
|
||||||
|
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
|
||||||
|
elif b[0:3] == bytes.fromhex("488d41"):
|
||||||
|
off = b[3]; mgr = owner + off
|
||||||
|
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
|
||||||
|
elif b[0:3] == bytes.fromhex("488b81"):
|
||||||
|
off = struct.unpack_from("<I", b, 3)[0]; mgr = q(owner + off)
|
||||||
|
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
|
||||||
|
elif b[0:3] == bytes.fromhex("488b41"):
|
||||||
|
off = b[3]; mgr = q(owner + off)
|
||||||
|
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
|
||||||
|
elif b[0:2] == bytes.fromhex("488b") and b[2] == 0xc1:
|
||||||
|
mgr = owner; print(" getter returns owner itself")
|
||||||
|
else:
|
||||||
|
print(" getter shape unrecognised; trying owner as mgr")
|
||||||
|
mgr = owner
|
||||||
|
|
||||||
|
for label, mgr_try in (("resolved", mgr), ("owner", owner)):
|
||||||
|
try:
|
||||||
|
beg, end = q(mgr_try + 0x108), q(mgr_try + 0x110)
|
||||||
|
except OSError:
|
||||||
|
print(f" [{label}] +0x108/0x110 unreadable"); continue
|
||||||
|
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24*100000:
|
||||||
|
print(f" [{label}] vector implausible: {beg:#x}..{end:#x}")
|
||||||
|
continue
|
||||||
|
n = (end - beg) // 24
|
||||||
|
print(f"\n === club-item vector via {label}: {beg:#x}..{end:#x} {n} slot(s) ===")
|
||||||
|
hist = collections.Counter(); rows = []
|
||||||
|
for k in range(n):
|
||||||
|
try:
|
||||||
|
rec = q(beg + k*24 + 0x10)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not rec:
|
||||||
|
hist[("<null slot>", None)] += 1; continue
|
||||||
|
try:
|
||||||
|
r = rd(rec, 0xC0)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if len(r) < 0xC0: continue
|
||||||
|
ct, sub, st, cat = i32(r,0x4c), i32(r,0x50), i32(r,0x5c), i32(r,0x60)
|
||||||
|
team = i32(r,0x94); kt = struct.unpack_from("<H", r, 0xba)[0]
|
||||||
|
hist[(ct, sub)] += 1
|
||||||
|
rows.append((rec, ct, sub, st, cat, team, kt))
|
||||||
|
print(f" (cardtype, cardsubtypeid) histogram:")
|
||||||
|
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
|
||||||
|
tag = " <== KIT (selector needs this)" if key == (7, 9) else ""
|
||||||
|
print(f" {str(key):<18} x{c}{tag}")
|
||||||
|
print(f" cardtype 7 records: {sum(c for (ct,_),c in hist.items() if ct==7)}")
|
||||||
|
print(f"\n first 12 records:")
|
||||||
|
print(f" {'ptr':>14} {'ctype':>5} {'subtype':>7} {'state':>5} {'cat':>4} {'team':>5} {'kittype':>7}")
|
||||||
|
for rec, ct, sub, st, cat, team, kt in rows[:12]:
|
||||||
|
print(f" {rec:#14x} {ct:>5} {sub:>7} {st:>5} {cat:>4} {team:>5} {kt:>7}")
|
||||||
|
break
|
||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Byte-level diff of the two resident kit records in a live FIFA17 client.
|
||||||
|
|
||||||
|
The pre-match selector draws each kit from a clone query keyed on the record's
|
||||||
|
own fields, so if both tiles render identically the question is precisely: which
|
||||||
|
bytes of the home record differ from the away record? This prints every differing
|
||||||
|
offset with the known field names attached, and dumps the fields the decoded
|
||||||
|
clone query consumes.
|
||||||
|
|
||||||
|
Read-only. Never writes to the process.
|
||||||
|
|
||||||
|
Decoded query (FUN_1801c3480 -> FUN_1801c44b0):
|
||||||
|
teamtechid == record+0x94
|
||||||
|
teamkittypetechid == derived from itemState (101 -> 0 home, 102 -> 1 away)
|
||||||
|
year == record+0xba
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PID = int(sys.argv[1])
|
||||||
|
WANT = [int(a) for a in sys.argv[2:]] or [100004874, 100004873]
|
||||||
|
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
|
||||||
|
|
||||||
|
def rd(a, n):
|
||||||
|
mem.seek(a)
|
||||||
|
return mem.read(n)
|
||||||
|
|
||||||
|
|
||||||
|
def q(a):
|
||||||
|
return struct.unpack("<Q", rd(a, 8))[0]
|
||||||
|
|
||||||
|
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m:
|
||||||
|
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
named.sort()
|
||||||
|
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
|
||||||
|
if base is None:
|
||||||
|
sys.exit("CardsDLL mapping not found")
|
||||||
|
|
||||||
|
|
||||||
|
def live(static):
|
||||||
|
return base + (static - 0x180000000)
|
||||||
|
|
||||||
|
|
||||||
|
if rd(live(0x180026FEA), 5) != bytes.fromhex("ba75750000"):
|
||||||
|
sys.exit("SANITY FAILED - wrong base")
|
||||||
|
print(f" CardsDLL base = {base:#x} (sanity ok)")
|
||||||
|
|
||||||
|
owner = q(live(0x1802E6398))
|
||||||
|
sentinel = owner + 0x160C8
|
||||||
|
root = q(owner + 0x160D8)
|
||||||
|
|
||||||
|
# Known record fields, offset -> (name, width)
|
||||||
|
FIELDS = {
|
||||||
|
0x08: ("id", 8),
|
||||||
|
0x18: ("resourceId/definitionId", 4),
|
||||||
|
# Offsets per club_items.json `_record_map`, which is authoritative:
|
||||||
|
# cardassetid is +0x1c and assetId is +0x20 — NOT the other way round.
|
||||||
|
0x1C: ("cardassetid", 4),
|
||||||
|
0x20: ("assetId", 4),
|
||||||
|
0x38: ("discardValue", 4),
|
||||||
|
0x4C: ("cardtype", 4),
|
||||||
|
0x50: ("cardsubtypeid", 4),
|
||||||
|
0x5C: ("itemState", 4),
|
||||||
|
0x60: ("category(club slot)", 4),
|
||||||
|
0x8C: ("contract", 4),
|
||||||
|
0x94: ("teamid", 4),
|
||||||
|
0xB4: ("rating", 4),
|
||||||
|
0xB8: ("wire category", 1),
|
||||||
|
0xBA: ("year", 2),
|
||||||
|
0x148: ("nation", 4),
|
||||||
|
0x154: ("leagueId", 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def walk(node, out):
|
||||||
|
if not node or node == sentinel:
|
||||||
|
return
|
||||||
|
walk(q(node + 0x00), out)
|
||||||
|
# The record is EMBEDDED at node+0x28 — NOT a pointer stored there.
|
||||||
|
out.append((struct.unpack("<q", rd(node + 0x20, 8))[0], node + 0x28))
|
||||||
|
walk(q(node + 0x08), out)
|
||||||
|
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
walk(root, nodes)
|
||||||
|
recs = {k: v for k, v in nodes}
|
||||||
|
|
||||||
|
found = [(w, recs[w]) for w in WANT if w in recs]
|
||||||
|
if len(found) < 2:
|
||||||
|
sys.exit(f" need two resident kit records, found {[w for w, _ in found]}")
|
||||||
|
|
||||||
|
(id_a, ptr_a), (id_b, ptr_b) = found[0], found[1]
|
||||||
|
a = rd(ptr_a, 0x180)
|
||||||
|
b = rd(ptr_b, 0x180)
|
||||||
|
print(f" A = {id_a} @ {ptr_a:#x}")
|
||||||
|
print(f" B = {id_b} @ {ptr_b:#x}")
|
||||||
|
|
||||||
|
print("\n --- fields the clone query consumes ---")
|
||||||
|
for off in (0x94, 0x5C, 0xBA):
|
||||||
|
name = FIELDS[off][0]
|
||||||
|
w = FIELDS[off][1]
|
||||||
|
va = int.from_bytes(a[off : off + w], "little")
|
||||||
|
vb = int.from_bytes(b[off : off + w], "little")
|
||||||
|
flag = "" if va != vb else " <== IDENTICAL"
|
||||||
|
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{flag}")
|
||||||
|
|
||||||
|
print("\n --- every differing byte range ---")
|
||||||
|
diffs = [i for i in range(0x180) if a[i] != b[i]]
|
||||||
|
runs = []
|
||||||
|
for i in diffs:
|
||||||
|
if runs and i == runs[-1][1] + 1:
|
||||||
|
runs[-1][1] = i
|
||||||
|
else:
|
||||||
|
runs.append([i, i])
|
||||||
|
for s, e in runs:
|
||||||
|
named_field = next(
|
||||||
|
(n for o, (n, w) in FIELDS.items() if o <= s < o + w), "(unmapped)"
|
||||||
|
)
|
||||||
|
va = int.from_bytes(a[s : e + 1], "little")
|
||||||
|
vb = int.from_bytes(b[s : e + 1], "little")
|
||||||
|
print(f" +{s:#05x}..{e:#05x} {named_field:24} A={va:<12} B={vb}")
|
||||||
|
print(f"\n {len(diffs)} differing bytes in {len(runs)} runs")
|
||||||
|
|
||||||
|
print("\n --- known fields, side by side ---")
|
||||||
|
for off in sorted(FIELDS):
|
||||||
|
name, w = FIELDS[off]
|
||||||
|
va = int.from_bytes(a[off : off + w], "little")
|
||||||
|
vb = int.from_bytes(b[off : off + w], "little")
|
||||||
|
mark = " DIFFERS" if va != vb else ""
|
||||||
|
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{mark}")
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Remove the port-8081 DNAT rule that hijacks FIFA 17's roster/squad-update TLS.
|
||||||
|
#
|
||||||
|
# Why: the FUT squad update is https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml
|
||||||
|
# (TLS on port 8081). A DNAT rule rewriting dport 8081 -> 8299 sends that TLS
|
||||||
|
# handshake to the plain-HTTP staging UTAS host, which closes the connection.
|
||||||
|
# Proven: a probe to 10.10.0.120:8081 from this box arrives at the server as
|
||||||
|
# dport 8299. Result: "An error occurred downloading the FUT squad update."
|
||||||
|
#
|
||||||
|
# The rule also never redirected UTAS, which lives on :8443, not :8081.
|
||||||
|
#
|
||||||
|
# Read-only until it deletes; deletes only nat rules whose target port is 8299.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "== nat OUTPUT rules mentioning 8081 or 8299 =="
|
||||||
|
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== deleting DNAT rules that redirect to port 8299 =="
|
||||||
|
removed=0
|
||||||
|
# Delete by spec, repeatedly, until no matching rule remains.
|
||||||
|
while :; do
|
||||||
|
rule=$(iptables -t nat -S OUTPUT 2>/dev/null | grep -m1 -E '\-\-dport 8081 .*8299|to-destination [0-9.]+:8299')
|
||||||
|
[ -z "$rule" ] && break
|
||||||
|
spec=$(printf '%s' "$rule" | sed 's/^-A /-D /')
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
if iptables -t nat $spec 2>/dev/null; then
|
||||||
|
echo " removed: $rule"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
echo " FAILED to remove: $rule" >&2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$removed" -eq 0 ] && echo " (no matching rule found)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== remaining nat OUTPUT rules mentioning 8081 or 8299 =="
|
||||||
|
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== verifying the roster endpoint now presents the correct certificate =="
|
||||||
|
python3 - <<'PY'
|
||||||
|
import socket, ssl
|
||||||
|
host, port, sni = "10.10.0.120", 8081, "winter15.gosredirector.ea.com"
|
||||||
|
try:
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
with socket.create_connection((host, port), 8) as s:
|
||||||
|
with ctx.wrap_socket(s, server_hostname=sni) as t:
|
||||||
|
der = t.getpeercert(True)
|
||||||
|
cn = dict(x[0] for x in t.getpeercert().get("subject", ()))
|
||||||
|
print(f" PASS {host}:{port} sni={sni} {t.version()} der={len(der)}B subject={cn}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL {host}:{port} sni={sni} -> {type(e).__name__}: {e}")
|
||||||
|
print(" The roster path is still broken; do not relaunch yet.")
|
||||||
|
PY
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Hunt for specific wire instance ids anywhere in the client's writable memory.
|
||||||
|
|
||||||
|
Answers whether a served item was materialised into a record at all, versus
|
||||||
|
materialised but not attached to a collection. A record is recognised by its
|
||||||
|
established layout: id at +0x08, resourceId at +0x18, cardtype at +0x4c.
|
||||||
|
|
||||||
|
Read-only. Never writes.
|
||||||
|
|
||||||
|
usage: probe_hunt.py PID id [id ...]
|
||||||
|
"""
|
||||||
|
import re, struct, sys
|
||||||
|
|
||||||
|
PID = int(sys.argv[1])
|
||||||
|
IDS = [int(a) for a in sys.argv[2:]]
|
||||||
|
if not IDS:
|
||||||
|
sys.exit("give at least one wire id")
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
|
||||||
|
regions = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", ln)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4).strip()
|
||||||
|
if "w" not in perms:
|
||||||
|
continue
|
||||||
|
if path.startswith("/") and not path.endswith(".dll") and not path.endswith(".exe"):
|
||||||
|
continue
|
||||||
|
regions.append((lo, hi, perms, path))
|
||||||
|
total = sum(hi - lo for lo, hi, _, _ in regions)
|
||||||
|
print(f" {len(regions)} writable regions, {total/2**20:.0f} MiB to scan")
|
||||||
|
|
||||||
|
needles = {struct.pack("<I", i): i for i in IDS}
|
||||||
|
hits = {i: [] for i in IDS}
|
||||||
|
CHUNK = 8 << 20
|
||||||
|
scanned = 0
|
||||||
|
for lo, hi, perms, path in regions:
|
||||||
|
a = lo
|
||||||
|
while a < hi:
|
||||||
|
n = min(CHUNK, hi - a)
|
||||||
|
try:
|
||||||
|
mem.seek(a)
|
||||||
|
data = mem.read(n)
|
||||||
|
except OSError:
|
||||||
|
a += n
|
||||||
|
continue
|
||||||
|
if not data:
|
||||||
|
a += n
|
||||||
|
continue
|
||||||
|
scanned += len(data)
|
||||||
|
for nd, wid in needles.items():
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
j = data.find(nd, start)
|
||||||
|
if j < 0:
|
||||||
|
break
|
||||||
|
start = j + 1
|
||||||
|
va = a + j
|
||||||
|
# a record would place this id at +0x08
|
||||||
|
rec = va - 0x08
|
||||||
|
try:
|
||||||
|
mem.seek(rec)
|
||||||
|
r = mem.read(0x100)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if len(r) < 0x100:
|
||||||
|
continue
|
||||||
|
ct = struct.unpack_from("<i", r, 0x4c)[0]
|
||||||
|
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||||
|
sub = struct.unpack_from("<i", r, 0x50)[0]
|
||||||
|
cat = struct.unpack_from("<i", r, 0x60)[0]
|
||||||
|
looks = 0 <= ct <= 32 and res > 1000
|
||||||
|
hits[wid].append((va, rec, ct, sub, cat, res, looks))
|
||||||
|
a += n
|
||||||
|
print(f" scanned {scanned/2**20:.0f} MiB\n")
|
||||||
|
for wid in IDS:
|
||||||
|
hs = hits[wid]
|
||||||
|
recs = [h for h in hs if h[6]]
|
||||||
|
print(f" id {wid}: {len(hs)} raw occurrence(s), {len(recs)} record-shaped")
|
||||||
|
for va, rec, ct, sub, cat, res, _ in recs[:6]:
|
||||||
|
print(f" record {rec:#x}: cardtype={ct} subtype={sub} category={cat} resourceId={res}")
|
||||||
|
if not recs:
|
||||||
|
print(" NOT MATERIALISED as a record anywhere in writable memory")
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Identify every resident record by its wire instance id.
|
||||||
|
|
||||||
|
Record layout established from known wire values:
|
||||||
|
+0x08 id (wire instance) +0x18 resourceId +0x1c/+0x20 assetId
|
||||||
|
+0x38 discardValue +0x4c cardtype +0x50 cardsubtypeid
|
||||||
|
+0x5c itemState +0x60 category +0x94 teamid
|
||||||
|
+0xb4 rating +0xba teamkittypetechid (u16)
|
||||||
|
|
||||||
|
Walks the contiguous 0x180-stride pool around the manager slot record so records
|
||||||
|
that are resident but not in any collection are still seen. Read-only.
|
||||||
|
|
||||||
|
usage: probe_ids.py PID [expected_id ...]
|
||||||
|
"""
|
||||||
|
import re, struct, sys
|
||||||
|
|
||||||
|
PID = int(sys.argv[1])
|
||||||
|
WANT = {int(a) for a in sys.argv[2:]}
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
|
||||||
|
def rd(a, n):
|
||||||
|
mem.seek(a); return mem.read(n)
|
||||||
|
def q(a):
|
||||||
|
return struct.unpack("<Q", rd(a, 8))[0]
|
||||||
|
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m:
|
||||||
|
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
named.sort()
|
||||||
|
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||||
|
live = lambda s: base + (s - 0x180000000)
|
||||||
|
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||||
|
sys.exit("SANITY FAILED")
|
||||||
|
owner = q(live(0x1802e6398))
|
||||||
|
mgr = owner + 0x1f9d8
|
||||||
|
RECSZ = 0x180
|
||||||
|
|
||||||
|
def dec(rec):
|
||||||
|
r = rd(rec, 0x180)
|
||||||
|
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||||
|
return dict(id=struct.unpack_from("<I", r, 0x8)[0], res=struct.unpack_from("<I", r, 0x18)[0],
|
||||||
|
ct=g(0x4c), sub=g(0x50), st=g(0x5c), cat=g(0x60), team=g(0x94),
|
||||||
|
rating=struct.unpack_from("<I", r, 0xb4)[0],
|
||||||
|
kt=struct.unpack_from("<H", r, 0xba)[0])
|
||||||
|
|
||||||
|
mgr_rec = q(mgr + 0xc0 + 0x10)
|
||||||
|
print(f" manager-slot record = {mgr_rec:#x}")
|
||||||
|
anchor = mgr_rec if mgr_rec else q(q(mgr + 0xd8) + 0x10)
|
||||||
|
|
||||||
|
# walk backwards to the start of the contiguous run, then forwards
|
||||||
|
lo = anchor
|
||||||
|
for _ in range(64):
|
||||||
|
prev = lo - RECSZ
|
||||||
|
try:
|
||||||
|
d = dec(prev)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not (0 < d["ct"] < 64) or d["id"] == 0:
|
||||||
|
break
|
||||||
|
lo = prev
|
||||||
|
|
||||||
|
print(f" pool run starts at {lo:#x}\n")
|
||||||
|
print(f" {'idx':>3} {'addr':>12} {'id':>10} {'resource':>9} {'ct':>3} {'sub':>4} "
|
||||||
|
f"{'st':>3} {'cat':>4} {'team':>5} {'rate':>5} {'kt':>6}")
|
||||||
|
found = {}
|
||||||
|
k = 0
|
||||||
|
addr = lo
|
||||||
|
while k < 48:
|
||||||
|
try:
|
||||||
|
d = dec(addr)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if d["id"] == 0 and d["ct"] == 0:
|
||||||
|
break
|
||||||
|
tag = ""
|
||||||
|
if d["ct"] == 7:
|
||||||
|
tag = " <== CARDTYPE 7"
|
||||||
|
if d["id"] in WANT:
|
||||||
|
tag += " <== WANTED"
|
||||||
|
found[d["id"]] = addr
|
||||||
|
slot = " [manager slot]" if addr == mgr_rec else ""
|
||||||
|
print(f" {k:>3} {addr:#12x} {d['id']:>10} {d['res']:>9} {d['ct']:>3} {d['sub']:>4} "
|
||||||
|
f"{d['st']:>3} {d['cat']:>4} {d['team']:>5} {d['rating']:>5} {d['kt']:>6}{tag}{slot}")
|
||||||
|
addr += RECSZ
|
||||||
|
k += 1
|
||||||
|
|
||||||
|
if WANT:
|
||||||
|
print(f"\n wanted ids: {sorted(WANT)}")
|
||||||
|
for w in sorted(WANT):
|
||||||
|
print(f" {w}: {'FOUND at ' + hex(found[w]) if w in found else 'NOT RESIDENT'}")
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Map FIFA 17 resident record offsets using UNIQUE wire values as ground truth.
|
||||||
|
|
||||||
|
v2: identifies each record by its wire instance id (large, unique) and only
|
||||||
|
accepts a field mapping when the value is distinctive (>= 16) and the same
|
||||||
|
offset holds the right value for EVERY identified record. This avoids the v1
|
||||||
|
failure where cardsubtypeid == 0 matched every zeroed field in the struct.
|
||||||
|
|
||||||
|
Read-only. Never writes.
|
||||||
|
|
||||||
|
usage: probe_layout2.py PID squad_active.json
|
||||||
|
"""
|
||||||
|
import re, struct, sys, json, collections
|
||||||
|
|
||||||
|
PID = int(sys.argv[1])
|
||||||
|
SQUAD = json.load(open(sys.argv[2]))
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
|
||||||
|
def rd(a, n):
|
||||||
|
mem.seek(a); return mem.read(n)
|
||||||
|
def q(a):
|
||||||
|
return struct.unpack("<Q", rd(a, 8))[0]
|
||||||
|
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m:
|
||||||
|
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
named.sort()
|
||||||
|
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||||
|
live = lambda s: base + (s - 0x180000000)
|
||||||
|
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||||
|
sys.exit("SANITY FAILED")
|
||||||
|
owner = q(live(0x1802e6398))
|
||||||
|
mgr = owner + 0x1f9d8
|
||||||
|
RECSZ = 0x180
|
||||||
|
|
||||||
|
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
|
||||||
|
recs = [r for r in (q(beg + k*24 + 0x10) for k in range((end - beg)//24)) if r]
|
||||||
|
|
||||||
|
wire = {}
|
||||||
|
for p in SQUAD["players"]:
|
||||||
|
it = p.get("itemData") or {}
|
||||||
|
if it.get("id"):
|
||||||
|
wire[it["id"]] = it
|
||||||
|
|
||||||
|
# --- identify each record by its wire instance id ---
|
||||||
|
ident = {}
|
||||||
|
for rec in recs:
|
||||||
|
r = rd(rec, RECSZ)
|
||||||
|
for off in range(0, RECSZ - 4, 4):
|
||||||
|
v = struct.unpack_from("<I", r, off)[0]
|
||||||
|
if v in wire:
|
||||||
|
ident.setdefault(rec, (v, off))
|
||||||
|
break
|
||||||
|
print(f" resident player records: {len(recs)}, identified: {len(ident)}")
|
||||||
|
id_offs = collections.Counter(o for _, o in ident.values())
|
||||||
|
print(f" wire-id offset candidates: {[(hex(o), c) for o, c in id_offs.most_common()]}")
|
||||||
|
|
||||||
|
FIELDS = ("id", "resourceId", "assetId", "definitionId", "cardassetid", "rating",
|
||||||
|
"teamid", "nation", "leagueId", "contract", "fitness", "playStyle",
|
||||||
|
"discardValue", "cardsubtypeid", "owners", "rareflag")
|
||||||
|
# --- for every offset, does it hold field F for every identified record? ---
|
||||||
|
consistent = {}
|
||||||
|
for off in range(0, RECSZ - 4, 4):
|
||||||
|
for f in FIELDS:
|
||||||
|
ok = 0; total = 0; distinct = set()
|
||||||
|
for rec, (wid, _) in ident.items():
|
||||||
|
it = wire[wid]
|
||||||
|
v = it.get(f)
|
||||||
|
if not isinstance(v, int) or v < 16: # require distinctive values
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
got = struct.unpack_from("<I", rd(rec, RECSZ), off)[0]
|
||||||
|
if got == v:
|
||||||
|
ok += 1; distinct.add(v)
|
||||||
|
if total >= 5 and ok == total and len(distinct) >= 2:
|
||||||
|
consistent.setdefault(off, []).append((f, total, len(distinct)))
|
||||||
|
|
||||||
|
print(f"\n === offsets consistently holding a distinctive wire field ===")
|
||||||
|
for off in sorted(consistent):
|
||||||
|
for f, total, nd in consistent[off]:
|
||||||
|
print(f" +0x{off:<4x} {f:14s} (matched {total}/{total} records, {nd} distinct values)")
|
||||||
|
|
||||||
|
# --- dump the manager and the three club staff for comparison ---
|
||||||
|
print(f"\n === cardtype-2 slot (manager) ===")
|
||||||
|
h = q(mgr + 0xc0 + 0x10)
|
||||||
|
if h:
|
||||||
|
r = rd(h, RECSZ)
|
||||||
|
for off in sorted(consistent):
|
||||||
|
f = consistent[off][0][0]
|
||||||
|
print(f" +0x{off:<4x} {f:14s} = {struct.unpack_from('<I', r, off)[0]}")
|
||||||
|
for name, off, sz in (("cardtype", 0x4c, 4), ("cardsubtypeid", 0x50, 4),
|
||||||
|
("itemState", 0x5c, 4), ("category", 0x60, 4)):
|
||||||
|
print(f" +0x{off:<4x} {name:14s} = {struct.unpack_from('<i', r, off)[0]}")
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enumerate FIFA 17's resident item map authoritatively.
|
||||||
|
|
||||||
|
Layout recovered from the lower_bound at 0x180119640:
|
||||||
|
owner+0x160c8 sentinel / end marker
|
||||||
|
owner+0x160d8 root
|
||||||
|
owner+0x160e8 count
|
||||||
|
node+0x00, node+0x08 children
|
||||||
|
node+0x20 key = wire instance id (qword)
|
||||||
|
node+0x28 the item record
|
||||||
|
On miss the client returns the static sentinel 0x1802c2a28 whose +0x10 is NULL.
|
||||||
|
|
||||||
|
Read-only. usage: probe_map2.py PID [id ...]
|
||||||
|
"""
|
||||||
|
import re, struct, sys, collections
|
||||||
|
|
||||||
|
PID = int(sys.argv[1]); WANT = {int(a) for a in sys.argv[2:]}
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
def rd(a, n):
|
||||||
|
mem.seek(a); return mem.read(n)
|
||||||
|
def q(a): return struct.unpack("<Q", rd(a, 8))[0]
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m: named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
named.sort()
|
||||||
|
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||||
|
if rd(base + (0x180026fea - 0x180000000), 5) != bytes.fromhex("ba75750000"):
|
||||||
|
sys.exit("SANITY FAILED")
|
||||||
|
owner = q(base + (0x1802e6398 - 0x180000000))
|
||||||
|
SENT, ROOT, COUNT = owner + 0x160c8, q(owner + 0x160d8), q(owner + 0x160e8) & 0xffffffff
|
||||||
|
print(f" owner={owner:#x} sentinel={SENT:#x} root={ROOT:#x} count={COUNT}")
|
||||||
|
|
||||||
|
nodes, seen, stack = [], set(), [ROOT]
|
||||||
|
while stack:
|
||||||
|
n = stack.pop()
|
||||||
|
if not n or n == SENT or n in seen or len(seen) > 5000:
|
||||||
|
continue
|
||||||
|
seen.add(n)
|
||||||
|
try:
|
||||||
|
h = rd(n, 0x30)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if len(h) < 0x30:
|
||||||
|
continue
|
||||||
|
nodes.append(n)
|
||||||
|
stack.append(struct.unpack_from("<Q", h, 0)[0])
|
||||||
|
stack.append(struct.unpack_from("<Q", h, 8)[0])
|
||||||
|
print(f" nodes reached: {len(nodes)} (count field says {COUNT})\n")
|
||||||
|
|
||||||
|
print(f" {'key':>11} {'record':>12} {'id':>10} {'resource':>10} {'ct':>3} {'sub':>4} {'st':>4} {'cat':>4}")
|
||||||
|
hist = collections.Counter(); found = {}
|
||||||
|
rows = []
|
||||||
|
for n in nodes:
|
||||||
|
key = q(n + 0x20)
|
||||||
|
rec = n + 0x28
|
||||||
|
try: r = rd(rec, 0x180)
|
||||||
|
except OSError: continue
|
||||||
|
if len(r) < 0x180: continue
|
||||||
|
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||||
|
rid = struct.unpack_from("<I", r, 0x8)[0]
|
||||||
|
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||||
|
ct, sub, st, cat = g(0x4c), g(0x50), g(0x5c), g(0x60)
|
||||||
|
hist[ct] += 1
|
||||||
|
if rid in WANT: found[rid] = rec
|
||||||
|
rows.append((key, rec, rid, res, ct, sub, st, cat))
|
||||||
|
for key, rec, rid, res, ct, sub, st, cat in sorted(rows):
|
||||||
|
tag = " <== CARDTYPE 7" if ct == 7 else (" <== WANTED" if rid in WANT else "")
|
||||||
|
print(f" {key:>11} {rec:#12x} {rid:>10} {res:>10} {ct:>3} {sub:>4} {st:>4} {cat:>4}{tag}")
|
||||||
|
print(f"\n cardtype histogram: {dict(sorted(hist.items()))} total={sum(hist.values())}")
|
||||||
|
for w in sorted(WANT):
|
||||||
|
print(f" id {w}: {'RESIDENT' if w in found else 'ABSENT'}")
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only scan of the record pool embedded in the club-model owner object.
|
||||||
|
|
||||||
|
The 18 resident player records sit at a fixed stride of 0x180 inside the owner
|
||||||
|
object, below the embedded manager subobject at owner+0x1f9d8. This walks that
|
||||||
|
pool to see whether storage for the five club items exists and what it holds.
|
||||||
|
Read-only. Never writes.
|
||||||
|
"""
|
||||||
|
import re, struct, sys
|
||||||
|
|
||||||
|
PID = int(sys.argv[1])
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
|
||||||
|
def rd(a, n):
|
||||||
|
mem.seek(a); return mem.read(n)
|
||||||
|
def q(a):
|
||||||
|
return struct.unpack("<Q", rd(a, 8))[0]
|
||||||
|
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m:
|
||||||
|
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
named.sort()
|
||||||
|
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||||
|
def live(s):
|
||||||
|
return base + (s - 0x180000000)
|
||||||
|
|
||||||
|
owner = q(live(0x1802e6398))
|
||||||
|
mgr = owner + 0x1f9d8
|
||||||
|
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
|
||||||
|
first = None
|
||||||
|
for k in range((end - beg) // 24):
|
||||||
|
r = q(beg + k * 24 + 0x10)
|
||||||
|
if r:
|
||||||
|
first = r; break
|
||||||
|
if first is None:
|
||||||
|
sys.exit("no populated player record to anchor the pool")
|
||||||
|
|
||||||
|
print(f" owner = {owner:#x} mgr = {mgr:#x} first record = {first:#x}")
|
||||||
|
print(f" record - owner = {first - owner:#x} pool room to mgr = {(mgr - first) // 0x180} slots of 0x180")
|
||||||
|
print()
|
||||||
|
hdr = f" {'idx':>3} {'addr':>12} {'ctype':>6} {'subtyp':>6} {'state':>6} {'cat':>4} {'team':>5} {'kittyp':>6} set"
|
||||||
|
print(hdr)
|
||||||
|
n = (mgr - first) // 0x180
|
||||||
|
for k in range(min(n, 40)):
|
||||||
|
a = first + k * 0x180
|
||||||
|
try:
|
||||||
|
r = rd(a, 0xC0)
|
||||||
|
except OSError:
|
||||||
|
print(f" {k:>3} {a:#12x} unreadable"); break
|
||||||
|
if len(r) < 0xC0:
|
||||||
|
break
|
||||||
|
ct, sub, st, cat, team = (struct.unpack_from("<i", r, o)[0] for o in (0x4c, 0x50, 0x5c, 0x60, 0x94))
|
||||||
|
kt = struct.unpack_from("<H", r, 0xba)[0]
|
||||||
|
nz = sum(1 for b in r if b)
|
||||||
|
flag = ""
|
||||||
|
if ct == 7:
|
||||||
|
flag = " <== CARDTYPE 7"
|
||||||
|
elif nz == 0:
|
||||||
|
flag = " (all zero)"
|
||||||
|
print(f" {k:>3} {a:#12x} {ct:>6} {sub:>6} {st:>6} {cat:>4} {team:>5} {kt:>6} {nz:>3}/192{flag}")
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only dump of RESIDENT record fields, for both the player and club-item vectors.
|
||||||
|
|
||||||
|
Purpose: the kit clone driver FUN_1801c3480 gates on record+0x60 (category) == 4.
|
||||||
|
No instruction in CardsDLL writes immediate 4 there, so this reads what value a
|
||||||
|
genuinely resident record actually carries. Read-only. Never writes.
|
||||||
|
|
||||||
|
mgr+0x0c0 cardtype-2 single slot
|
||||||
|
mgr+0x0d8..0x0e0 cardtype-1 (player) vector
|
||||||
|
mgr+0x108..0x110 club-item vector
|
||||||
|
record+0x4c cardtype +0x50 cardsubtypeid +0x5c itemState
|
||||||
|
record+0x60 category +0x94 teamid +0xba teamkittypetechid (u16)
|
||||||
|
"""
|
||||||
|
import re, struct, sys, collections
|
||||||
|
|
||||||
|
PID = int(sys.argv[1])
|
||||||
|
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||||
|
|
||||||
|
def rd(a, n):
|
||||||
|
mem.seek(a); return mem.read(n)
|
||||||
|
def q(a):
|
||||||
|
return struct.unpack("<Q", rd(a, 8))[0]
|
||||||
|
def i32(b, o):
|
||||||
|
return struct.unpack_from("<i", b, o)[0]
|
||||||
|
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{PID}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m:
|
||||||
|
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
named.sort()
|
||||||
|
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
|
||||||
|
if base is None:
|
||||||
|
sys.exit("CardsDLL mapping not found")
|
||||||
|
def live(static):
|
||||||
|
return base + (static - 0x180000000)
|
||||||
|
|
||||||
|
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||||
|
sys.exit("SANITY FAILED - wrong base")
|
||||||
|
print(f" CardsDLL base = {base:#x} (sanity ok)")
|
||||||
|
|
||||||
|
owner = q(live(0x1802e6398))
|
||||||
|
b = rd(q(owner) + 0x4e8, 12)
|
||||||
|
b = rd(struct.unpack("<Q", struct.pack("<Q", q(q(owner) + 0x4e8)))[0], 12)
|
||||||
|
getter = q(q(owner) + 0x4e8)
|
||||||
|
gb = rd(getter, 12)
|
||||||
|
if gb[0:3] == bytes.fromhex("488d81"):
|
||||||
|
mgr = owner + struct.unpack_from("<I", gb, 3)[0]
|
||||||
|
elif gb[0:3] == bytes.fromhex("488d41"):
|
||||||
|
mgr = owner + gb[3]
|
||||||
|
else:
|
||||||
|
sys.exit(f"unexpected getter shape {gb.hex(' ')}")
|
||||||
|
print(f" owner = {owner:#x} mgr = {mgr:#x}")
|
||||||
|
|
||||||
|
FIELDS = ("ctype", "subtype", "state", "cat", "team", "kittype")
|
||||||
|
def decode(rec):
|
||||||
|
r = rd(rec, 0xC0)
|
||||||
|
if len(r) < 0xC0:
|
||||||
|
return None
|
||||||
|
return (i32(r, 0x4c), i32(r, 0x50), i32(r, 0x5c), i32(r, 0x60),
|
||||||
|
i32(r, 0x94), struct.unpack_from("<H", r, 0xba)[0])
|
||||||
|
|
||||||
|
for label, vbeg, vend in (("players (cardtype 1)", mgr + 0xd8, mgr + 0xe0),
|
||||||
|
("club items", mgr + 0x108, mgr + 0x110)):
|
||||||
|
try:
|
||||||
|
beg, end = q(vbeg), q(vend)
|
||||||
|
except OSError:
|
||||||
|
print(f"\n {label}: vector unreadable")
|
||||||
|
continue
|
||||||
|
span = end - beg
|
||||||
|
print(f"\n === {label}: {beg:#x}..{end:#x} span={span} ===")
|
||||||
|
if not (0 < beg <= end) or span > 24 * 100000:
|
||||||
|
print(" implausible vector, skipping")
|
||||||
|
continue
|
||||||
|
# resolve stride: the element must contain a plausible heap pointer
|
||||||
|
for stride, ptr_off in ((24, 0x10), (16, 0x08), (8, 0x00)):
|
||||||
|
if span % stride:
|
||||||
|
continue
|
||||||
|
n = span // stride
|
||||||
|
recs, nulls = [], []
|
||||||
|
ok = True
|
||||||
|
for k in range(n):
|
||||||
|
try:
|
||||||
|
rec = q(beg + k * stride + ptr_off)
|
||||||
|
except OSError:
|
||||||
|
ok = False; break
|
||||||
|
if not rec:
|
||||||
|
nulls.append(k); continue
|
||||||
|
d = decode(rec)
|
||||||
|
if d is None:
|
||||||
|
ok = False; break
|
||||||
|
recs.append((k, rec, d))
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
print(f" stride {stride} (ptr at +{ptr_off:#x}): {n} slots, {len(recs)} populated, {len(nulls)} null")
|
||||||
|
if not recs and len(nulls) != n:
|
||||||
|
continue
|
||||||
|
hist = collections.Counter(d[0:2] for _, _, d in recs)
|
||||||
|
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
|
||||||
|
print(f" (cardtype,subtype)={key} x{c}")
|
||||||
|
# The SLOT INDEX is load-bearing evidence: the squad parser's `actives`
|
||||||
|
# arm writes element i to slot `r15d + i`, and r15d is shared scratch
|
||||||
|
# that other atom handlers clobber. Which slots are filled therefore
|
||||||
|
# reveals the index the parse actually started from.
|
||||||
|
print(f" {'slot':>4} {'ptr':>14} " + " ".join(f"{f:>8}" for f in FIELDS))
|
||||||
|
for k, rec, d in recs[:8]:
|
||||||
|
print(f" {k:>4} {rec:#14x} " + " ".join(f"{v:>8}" for v in d))
|
||||||
|
if nulls:
|
||||||
|
print(f" empty slots: {nulls[:16]}")
|
||||||
|
cats = collections.Counter(d[3] for _, _, d in recs)
|
||||||
|
if cats:
|
||||||
|
print(f" CATEGORY (+0x60) distribution: {dict(cats)}")
|
||||||
|
break
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Native proof for the FIFA 17 kit milestone: does the client now hold resident
|
||||||
|
# cardtype-7 records, and are the served kit ids among them?
|
||||||
|
#
|
||||||
|
# Auto-detects the live FIFA17.exe pid and walks the resident item map at
|
||||||
|
# owner+0x160c8 (root +0x160d8, key = wire instance id at node+0x20, record at
|
||||||
|
# node+0x28, count at owner+0x160e8). Read-only; never writes to the process.
|
||||||
|
#
|
||||||
|
# BEFORE this fix the map held 22 records with cardtype histogram {1:18, 2:1,
|
||||||
|
# 4:2, 10:1} and both kit ids ABSENT.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PID=$(for p in /proc/[0-9]*; do
|
||||||
|
[ "$(cat "$p/comm" 2>/dev/null)" = "FIFA17.exe" ] && echo "${p#/proc/}"
|
||||||
|
done | head -1)
|
||||||
|
|
||||||
|
if [ -z "$PID" ]; then
|
||||||
|
echo " FIFA17.exe is not running - launch the game and enter FUT first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " live FIFA17 pid = $PID"
|
||||||
|
echo
|
||||||
|
|
||||||
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
python3 probe_map2.py "$PID" 100004873 100004874 100004870
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ================ squad survival + slot indices ================"
|
||||||
|
# The kit milestone is only real if the REST of the squad survives with it.
|
||||||
|
# A populated `squad.actives` was once seen to leave the map holding just the
|
||||||
|
# 2 kits with a fully null 23-slot player vector and an empty starting 11, so
|
||||||
|
# the player-vector fill below is a PASS/FAIL gate, not decoration.
|
||||||
|
#
|
||||||
|
# The club-item slot indices are the other half: the parser writes element i to
|
||||||
|
# slot r15d+i, and r15d is scratch other atom handlers clobber. Kits landing
|
||||||
|
# somewhere other than slots 0 and 1 means the index did not start at zero.
|
||||||
|
python3 probe_resident_fields.py "$PID"
|
||||||
Executable
+225
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Watch FIFA 17's resident club-item store and log every change, with timestamps.
|
||||||
|
|
||||||
|
Read-only. Waits for FIFA17.exe to appear, re-resolves the store each tick (the
|
||||||
|
manager is reallocated across logins), and appends one line per CHANGE so the
|
||||||
|
output can be aligned against the staging host's route log by wall clock.
|
||||||
|
|
||||||
|
Purpose: answer "after which response does a resident club item first appear?"
|
||||||
|
without reversing the constructor first. Pair with
|
||||||
|
|
||||||
|
journalctl -u openfut-staging-host --since <start> -o short-iso
|
||||||
|
|
||||||
|
and compare timestamps.
|
||||||
|
|
||||||
|
Usage: watch_residency.py [--interval 1.0] [--out /path/log] [--once]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
CARDS_DLL = "CardsDLL_Win64_retail.dll"
|
||||||
|
OWNER_GLOBAL = 0x1802E6398 # FUN_18011a830: mov rax,[this]; ret
|
||||||
|
SANITY_VA = 0x180026FEA # mov edx,0x7575
|
||||||
|
SANITY_BYTES = bytes.fromhex("ba75750000")
|
||||||
|
IMAGE_BASE = 0x180000000
|
||||||
|
|
||||||
|
# item-record offsets, all previously proven (see Vault: Kit Selector APT Decode)
|
||||||
|
OFF = {"cardtype": 0x4C, "cardsubtypeid": 0x50, "itemState": 0x5C,
|
||||||
|
"category": 0x60, "teamid": 0x94}
|
||||||
|
OFF_KITTYPE_U16 = 0xBA
|
||||||
|
|
||||||
|
|
||||||
|
class Target:
|
||||||
|
"""One live FIFA17.exe, with the store chain resolved."""
|
||||||
|
|
||||||
|
def __init__(self, pid: int):
|
||||||
|
self.pid = pid
|
||||||
|
self.mem = open(f"/proc/{pid}/mem", "rb", buffering=0)
|
||||||
|
self.base = self._cards_base()
|
||||||
|
if self.base is None:
|
||||||
|
raise RuntimeError("CardsDLL mapping not found")
|
||||||
|
probe = self.rd(self.live(SANITY_VA), 5)
|
||||||
|
if probe != SANITY_BYTES:
|
||||||
|
raise RuntimeError(f"base sanity failed: {probe.hex(' ')}")
|
||||||
|
owner = self.q(self.live(OWNER_GLOBAL))
|
||||||
|
if not owner:
|
||||||
|
raise RuntimeError("owner object is null (not logged in yet)")
|
||||||
|
vt = self.q(owner)
|
||||||
|
getter = self.q(vt + 0x4E8)
|
||||||
|
b = self.rd(getter, 8)
|
||||||
|
# lea rax,[rcx+imm32] ; ret / lea rax,[rcx+imm8] ; ret
|
||||||
|
if b[0:3] == bytes.fromhex("488d81"):
|
||||||
|
self.mgr = owner + struct.unpack_from("<I", b, 3)[0]
|
||||||
|
elif b[0:3] == bytes.fromhex("488d41"):
|
||||||
|
self.mgr = owner + b[3]
|
||||||
|
elif b[0:3] == bytes.fromhex("488b81"):
|
||||||
|
self.mgr = self.q(owner + struct.unpack_from("<I", b, 3)[0])
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"unrecognised getter: {b.hex(' ')}")
|
||||||
|
|
||||||
|
# -- raw access ------------------------------------------------------
|
||||||
|
def rd(self, a: int, n: int) -> bytes:
|
||||||
|
self.mem.seek(a)
|
||||||
|
return self.mem.read(n)
|
||||||
|
|
||||||
|
def q(self, a: int) -> int:
|
||||||
|
return struct.unpack("<Q", self.rd(a, 8))[0]
|
||||||
|
|
||||||
|
def live(self, static: int) -> int:
|
||||||
|
return self.base + (static - IMAGE_BASE)
|
||||||
|
|
||||||
|
def _cards_base(self):
|
||||||
|
named = []
|
||||||
|
for ln in open(f"/proc/{self.pid}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||||
|
if m:
|
||||||
|
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||||
|
# NEAREST PRECEDING NAMED mapping: Wine maps PE sections anonymously and
|
||||||
|
# the Wine heap is also rwx, so permissions cannot identify a module.
|
||||||
|
for start, path in sorted(named):
|
||||||
|
if path.endswith(CARDS_DLL):
|
||||||
|
return start
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- the store -------------------------------------------------------
|
||||||
|
def vector(self, off_begin: int):
|
||||||
|
beg, end = self.q(self.mgr + off_begin), self.q(self.mgr + off_begin + 8)
|
||||||
|
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24 * 200000:
|
||||||
|
return None, 0
|
||||||
|
return beg, (end - beg) // 24
|
||||||
|
|
||||||
|
def records(self, off_begin: int):
|
||||||
|
beg, n = self.vector(off_begin)
|
||||||
|
out = []
|
||||||
|
if beg is None:
|
||||||
|
return out
|
||||||
|
for k in range(n):
|
||||||
|
try:
|
||||||
|
rec = self.q(beg + k * 24 + 0x10)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not rec:
|
||||||
|
out.append(None)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
r = self.rd(rec, 0xC0)
|
||||||
|
except OSError:
|
||||||
|
out.append(None)
|
||||||
|
continue
|
||||||
|
if len(r) < 0xC0:
|
||||||
|
out.append(None)
|
||||||
|
continue
|
||||||
|
f = {k2: struct.unpack_from("<i", r, v)[0] for k2, v in OFF.items()}
|
||||||
|
f["teamkittypetechid"] = struct.unpack_from("<H", r, OFF_KITTYPE_U16)[0]
|
||||||
|
f["ptr"] = rec
|
||||||
|
out.append(f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
club = self.records(0x108)
|
||||||
|
players = self.records(0xD8)
|
||||||
|
hist = collections.Counter(
|
||||||
|
(r["cardtype"], r["cardsubtypeid"]) for r in club if r
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"club_slots": len(club),
|
||||||
|
"club_filled": sum(1 for r in club if r),
|
||||||
|
"club_hist": dict(hist),
|
||||||
|
"club_records": [r for r in club if r],
|
||||||
|
"player_slots": len(players),
|
||||||
|
"player_filled": sum(1 for r in players if r),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid() -> int | None:
|
||||||
|
for d in os.listdir("/proc"):
|
||||||
|
if not d.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{d}/comm") as f:
|
||||||
|
if f.read().strip() == "FIFA17.exe":
|
||||||
|
return int(d)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(snap: dict) -> str:
|
||||||
|
parts = [
|
||||||
|
f"club={snap['club_filled']}/{snap['club_slots']}",
|
||||||
|
f"players={snap['player_filled']}/{snap['player_slots']}",
|
||||||
|
]
|
||||||
|
if snap["club_hist"]:
|
||||||
|
parts.append("hist=" + ",".join(
|
||||||
|
f"(ct{a},st{b})x{c}" for (a, b), c in sorted(snap["club_hist"].items())))
|
||||||
|
for r in snap["club_records"]:
|
||||||
|
parts.append(
|
||||||
|
"KIT[" if (r["cardtype"], r["cardsubtypeid"]) == (7, 9) else "rec[")
|
||||||
|
parts[-1] += (f"ptr={r['ptr']:#x} ct={r['cardtype']} st={r['cardsubtypeid']} "
|
||||||
|
f"state={r['itemState']} cat={r['category']} "
|
||||||
|
f"team={r['teamid']} kittype={r['teamkittypetechid']}]")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--interval", type=float, default=1.0)
|
||||||
|
ap.add_argument("--out", default="/home/alex/openfut-live/residency.log")
|
||||||
|
ap.add_argument("--once", action="store_true")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
sink = sys.stdout if a.out == "-" else open(a.out, "a", buffering=1)
|
||||||
|
|
||||||
|
def emit(msg: str) -> None:
|
||||||
|
line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}"
|
||||||
|
print(line, file=sink)
|
||||||
|
if sink is not sys.stdout:
|
||||||
|
print(line, flush=True)
|
||||||
|
|
||||||
|
emit("watch: start")
|
||||||
|
target = None
|
||||||
|
last = None
|
||||||
|
while True:
|
||||||
|
if target is None:
|
||||||
|
pid = find_pid()
|
||||||
|
if pid is None:
|
||||||
|
if a.once:
|
||||||
|
emit("watch: no FIFA17.exe"); return 1
|
||||||
|
time.sleep(a.interval); continue
|
||||||
|
try:
|
||||||
|
target = Target(pid)
|
||||||
|
emit(f"watch: attached pid={pid} cardsdll={target.base:#x} "
|
||||||
|
f"mgr={target.mgr:#x}")
|
||||||
|
last = None
|
||||||
|
except (OSError, RuntimeError) as e:
|
||||||
|
# not logged in yet, or the process died mid-resolve
|
||||||
|
if a.once:
|
||||||
|
emit(f"watch: not ready: {e}"); return 1
|
||||||
|
target = None
|
||||||
|
time.sleep(a.interval); continue
|
||||||
|
try:
|
||||||
|
snap = target.snapshot()
|
||||||
|
except (OSError, struct.error) as e:
|
||||||
|
emit(f"watch: detached ({e})")
|
||||||
|
target = None
|
||||||
|
if a.once:
|
||||||
|
return 1
|
||||||
|
continue
|
||||||
|
key = fmt(snap)
|
||||||
|
if key != last:
|
||||||
|
emit(key)
|
||||||
|
last = key
|
||||||
|
if a.once:
|
||||||
|
return 0
|
||||||
|
time.sleep(a.interval)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client?
|
||||||
|
|
||||||
|
READ-ONLY. Opens /proc/<pid>/mem for reading and scans. Writes nothing, sends
|
||||||
|
no input to the game, and never opens 'r+b'.
|
||||||
|
|
||||||
|
manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N]
|
||||||
|
[--control WIRE:RESOURCE ...]
|
||||||
|
|
||||||
|
Defaults describe the staging profile used to close the manager milestone; pass
|
||||||
|
the flags for any other profile.
|
||||||
|
|
||||||
|
WHAT THIS DECIDES
|
||||||
|
-----------------
|
||||||
|
FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by
|
||||||
|
two different routes:
|
||||||
|
|
||||||
|
players : atom 568 -> per-element atoms 355 index / 363 itemData /
|
||||||
|
378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser
|
||||||
|
on the NESTED itemData object.
|
||||||
|
manager : atom 424 -> array loop at 0x18013da29 calls that same item parser
|
||||||
|
DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step.
|
||||||
|
|
||||||
|
So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served
|
||||||
|
as {id, itemData:{...}, dream} the parser read only the two keys that happen to
|
||||||
|
be item atoms -- id and dream -- and left resourceId at 0. resourceId is the
|
||||||
|
merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18),
|
||||||
|
so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in
|
||||||
|
OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md".
|
||||||
|
|
||||||
|
CONTROLS
|
||||||
|
--------
|
||||||
|
manager wire id the instance id. Present even when BROKEN, because `id` is
|
||||||
|
an item atom the parser reads at element level. Its
|
||||||
|
presence proves the element was parsed and therefore proves
|
||||||
|
nothing about registration -- do not use it as the verdict.
|
||||||
|
manager resourceId THE VERDICT. Resident => the merge key survived the load.
|
||||||
|
player wire id and positive controls. Players demonstrably render, so if their
|
||||||
|
player resourceId resourceIds are absent the squad simply is not loaded yet
|
||||||
|
and the run is INCONCLUSIVE, not a failure.
|
||||||
|
|
||||||
|
RESIDENT-MANAGER HIT
|
||||||
|
--------------------
|
||||||
|
A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in
|
||||||
|
a readable private mapping. Corroborate with the record context printed below:
|
||||||
|
a real item record carries resourceId eight words ahead of its wire id, which
|
||||||
|
is the layout the player controls exhibit. Hits without that shape are usually
|
||||||
|
id lists or unrelated integers -- the layout, not the raw count, is the proof.
|
||||||
|
|
||||||
|
LAYOUT ASSUMPTION (the only one)
|
||||||
|
--------------------------------
|
||||||
|
Item records place resourceId 0x20 bytes before the wire id. Measured, both
|
||||||
|
sides:
|
||||||
|
|
||||||
|
before b91e707 (pid 126936) -- manager parsed, merge key absent
|
||||||
|
player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7
|
||||||
|
player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7
|
||||||
|
manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7
|
||||||
|
|
||||||
|
after b91e707 (pid 134118) -- same layout, key present
|
||||||
|
player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0
|
||||||
|
player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0
|
||||||
|
manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0
|
||||||
|
|
||||||
|
Addresses shift every session and are recorded only as provenance; nothing here
|
||||||
|
depends on them. The tool re-derives everything by scanning.
|
||||||
|
|
||||||
|
EXIT CODES (fail-closed)
|
||||||
|
------------------------
|
||||||
|
0 PASS manager resourceId resident, controls present
|
||||||
|
1 FAIL controls present, manager resourceId absent
|
||||||
|
2 NO PROCESS no FIFA17.exe, or /proc/<pid>/mem unreadable
|
||||||
|
3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the
|
||||||
|
squad screen. Deliberately NOT 0: absent controls mean the
|
||||||
|
probe proved nothing.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Staging profile defaults (override on the command line).
|
||||||
|
DEF_MANAGER_WIRE = 100004870
|
||||||
|
DEF_MANAGER_RESOURCE = 1000509
|
||||||
|
DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)]
|
||||||
|
|
||||||
|
# Item record layout: resourceId sits this far BEFORE the wire id.
|
||||||
|
RESOURCE_BACK_OFF = 0x20
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid():
|
||||||
|
"""The Wine process whose comm is FIFA17.exe (same rule as memtool.py)."""
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, "comm")) as fh:
|
||||||
|
if fh.read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def regions(pid):
|
||||||
|
"""Readable private mappings worth scanning.
|
||||||
|
|
||||||
|
Skips device/memfd mappings and anything over 512 MiB (the big reserved
|
||||||
|
ranges are not where parsed records live and dominate the runtime).
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
with open(f"/proc/{pid}/maps") as fh:
|
||||||
|
for line in fh:
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
|
||||||
|
perms, path = m.group(3), m.group(4)
|
||||||
|
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||||
|
continue
|
||||||
|
if hi - lo > 512 * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
out.append((lo, hi))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scan(pid, needles, ctx_before=0x40, ctx_after=0x40):
|
||||||
|
"""4-byte-aligned little-endian i32 search; keeps a window around each hit."""
|
||||||
|
found = {n: [] for n in needles}
|
||||||
|
pats = {n: struct.pack("<i", n) for n in needles}
|
||||||
|
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
|
||||||
|
for lo, hi in regions(pid):
|
||||||
|
try:
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
except (OSError, ValueError, OverflowError):
|
||||||
|
continue # torn-down or unreadable mapping; not a failure
|
||||||
|
for n, pat in pats.items():
|
||||||
|
i = buf.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
if i % 4 == 0:
|
||||||
|
found[n].append(
|
||||||
|
(lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before))
|
||||||
|
)
|
||||||
|
i = buf.find(pat, i + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def words(blob, centre, before=8, after=4):
|
||||||
|
cells = []
|
||||||
|
for k in range(-before, after):
|
||||||
|
o = centre + k * 4
|
||||||
|
if 0 <= o <= len(blob) - 4:
|
||||||
|
cells.append(str(struct.unpack_from("<i", blob, o)[0]))
|
||||||
|
return " ".join(cells)
|
||||||
|
|
||||||
|
|
||||||
|
def record_shaped(blob, centre, resource):
|
||||||
|
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
|
||||||
|
item-record layout, as opposed to an incidental integer match."""
|
||||||
|
o = centre - RESOURCE_BACK_OFF
|
||||||
|
if o < 0 or o > len(blob) - 4:
|
||||||
|
return False
|
||||||
|
return struct.unpack_from("<i", blob, o)[0] == resource
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="read-only manager registration probe")
|
||||||
|
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
|
||||||
|
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
|
||||||
|
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
|
||||||
|
ap.add_argument(
|
||||||
|
"--control",
|
||||||
|
action="append",
|
||||||
|
metavar="WIRE:RESOURCE",
|
||||||
|
help="player positive control; repeatable (default: the staging pair)",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
controls = DEF_CONTROLS
|
||||||
|
if args.control:
|
||||||
|
try:
|
||||||
|
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
|
||||||
|
except ValueError:
|
||||||
|
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
pid = args.pid or find_pid()
|
||||||
|
if not pid:
|
||||||
|
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
|
||||||
|
return 2
|
||||||
|
if not os.access(f"/proc/{pid}/mem", os.R_OK):
|
||||||
|
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
|
||||||
|
return 2
|
||||||
|
print(f" pid={pid}")
|
||||||
|
|
||||||
|
needles = [args.manager_wire, args.manager_resource]
|
||||||
|
for w, r in controls:
|
||||||
|
needles += [w, r]
|
||||||
|
try:
|
||||||
|
res = scan(pid, sorted(set(needles)))
|
||||||
|
except OSError as e:
|
||||||
|
print(f" cannot read /proc/{pid}/mem: {e}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print("\n ===== hit counts =====")
|
||||||
|
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
|
||||||
|
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
|
||||||
|
f"hits={len(res[args.manager_resource])}")
|
||||||
|
for w, r in controls:
|
||||||
|
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
|
||||||
|
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
|
||||||
|
|
||||||
|
print("\n ===== record context (8 words before the id, then the id) =====")
|
||||||
|
shaped = {"manager": 0}
|
||||||
|
for tag, wire, resource in (
|
||||||
|
[("manager", args.manager_wire, args.manager_resource)]
|
||||||
|
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
|
||||||
|
):
|
||||||
|
marked = 0
|
||||||
|
for addr, blob, centre in res[wire]:
|
||||||
|
ok = record_shaped(blob, centre, resource)
|
||||||
|
if ok:
|
||||||
|
marked += 1
|
||||||
|
if marked <= 2 or ok:
|
||||||
|
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
|
||||||
|
f"{words(blob, centre)}")
|
||||||
|
if marked >= 2:
|
||||||
|
break
|
||||||
|
shaped[tag] = marked
|
||||||
|
|
||||||
|
ctl_keys = sum(len(res[r]) for _w, r in controls)
|
||||||
|
mgr_keys = len(res[args.manager_resource])
|
||||||
|
|
||||||
|
print("\n ===== verdict =====")
|
||||||
|
if ctl_keys == 0:
|
||||||
|
print(" INCONCLUSIVE: no player resourceId control is resident, so the squad")
|
||||||
|
print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)")
|
||||||
|
return 3
|
||||||
|
if mgr_keys == 0:
|
||||||
|
print(f" FAIL: manager resourceId {args.manager_resource} is absent while "
|
||||||
|
f"{ctl_keys} player")
|
||||||
|
print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.")
|
||||||
|
return 1
|
||||||
|
print(f" PASS: manager resourceId {args.manager_resource} is resident "
|
||||||
|
f"({mgr_keys} hits, {shaped['manager']} in item-record layout).")
|
||||||
|
print(" The merge key survived the load; the broken projection had 0.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+189
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17 provider dispatch and ACTION_ADVANCE delivery boundaries.
|
||||||
|
|
||||||
|
This probe correlates the global UI dispatch of FUT_CREATE_MATCH_DP and
|
||||||
|
FUT_GET_MATCH_KITS_DP, the subscribed CardsDLL provider, the internal 0x7546
|
||||||
|
create-response callback that can replay FUT_CREATE_MATCH_DP, and the final
|
||||||
|
native-to-UI bridge. At global dispatch, r8d is the provider ID and rdx is the
|
||||||
|
payload; neither register is a screen key.
|
||||||
|
|
||||||
|
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||||
|
It never writes client memory and never drives game input.
|
||||||
|
|
||||||
|
match_advance_trace.py [pid] [--output PATH]
|
||||||
|
match_advance_trace.py --print-script [pid]
|
||||||
|
match_advance_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
FIFA_MODULE = "FIFA17.exe"
|
||||||
|
PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899"
|
||||||
|
GLOBAL_UI_DISPATCH_RVA = 0x80D1070
|
||||||
|
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||||
|
PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41
|
||||||
|
|
||||||
|
|
||||||
|
def module_mapping(pid: int, module: str) -> tuple[int, str]:
|
||||||
|
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
fields = line.split(maxsplit=5)
|
||||||
|
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||||
|
if not path.endswith(module):
|
||||||
|
continue
|
||||||
|
return int(fields[0].split("-", 1)[0], 16), path
|
||||||
|
raise RuntimeError(f"{module} is not mapped in PID {pid}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file(path: str, expected: str, label: str) -> None:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
actual = digest.hexdigest()
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}")
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"provider": cards_base + transition.PROVIDER_DISPATCH_RVA,
|
||||||
|
"global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||||
|
"controller": cards_base + CREATE_MATCH_CONTROLLER_RVA,
|
||||||
|
"bridge": cards_base + PROVIDER_BRIDGE_CALL_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(pid: int, cards_base: int, fifa_base: int, output: str) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
address = trace_addresses(cards_base, fifa_base)
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted on
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
|
||||||
|
hbreak *0x{address['provider']:x}
|
||||||
|
condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x payload=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['global_dispatch']:x}
|
||||||
|
condition 2 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x payload=%p manager=%p caller=%p\\n", $_thread, $r8d, $rdx, $rcx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['controller']:x}
|
||||||
|
condition 3 $edx == 0x7546
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['bridge']:x}
|
||||||
|
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000, 0x140000000)
|
||||||
|
assert address == {
|
||||||
|
"provider": 0x1801A4CD0,
|
||||||
|
"global_dispatch": 0x1480D1070,
|
||||||
|
"controller": 0x1800BF950,
|
||||||
|
"bridge": 0x1801A4D41,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log")
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "CREATE_MATCH_CONTROLLER" in script
|
||||||
|
assert "PROVIDER_BRIDGE" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_advance_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or transition.find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
fifa_base, fifa_path = module_mapping(pid, FIFA_MODULE)
|
||||||
|
validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-advance-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-match-advance-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace the FIFA17 ACTION_CREATE_MATCH-to-provider lifecycle.
|
||||||
|
|
||||||
|
The probe correlates:
|
||||||
|
|
||||||
|
* the select-team action handler for UIF action IDs 0x7574..0x757b;
|
||||||
|
* DataManager's request dispatch for FutCreateMatchServerResponse (0x7546);
|
||||||
|
* the concrete FutCreateMatchServerResponse data-source request method;
|
||||||
|
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||||
|
|
||||||
|
Static decoding identifies action 0x7577 as the branch that constructs the
|
||||||
|
create-match request and calls DataManager for source 0x7546. The trace proves
|
||||||
|
whether that authentic trigger executes in the failing flow. It uses four
|
||||||
|
hardware-assisted execution breakpoints, never writes client memory, and never
|
||||||
|
drives game input.
|
||||||
|
|
||||||
|
match_create_action_trace.py [pid] [--output PATH]
|
||||||
|
match_create_action_trace.py --print-script [pid]
|
||||||
|
match_create_action_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_advance_trace as advance
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
SELECT_TEAM_ACTION_HANDLER_RVA = 0x0BFCC0
|
||||||
|
DATA_MANAGER_REQUEST_RVA = 0x80D2340
|
||||||
|
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||||
|
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||||
|
FIRST_SELECT_TEAM_ACTION = 0x7574
|
||||||
|
LAST_SELECT_TEAM_ACTION = 0x757B
|
||||||
|
ACTION_CREATE_MATCH = 0x7577
|
||||||
|
CREATE_DATA_SOURCE = 0x7546
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"action_handler": cards_base + SELECT_TEAM_ACTION_HANDLER_RVA,
|
||||||
|
"manager_request": fifa_base + DATA_MANAGER_REQUEST_RVA,
|
||||||
|
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||||
|
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(
|
||||||
|
pid: int, cards_base: int, fifa_base: int, output: str
|
||||||
|
) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
address = trace_addresses(cards_base, fifa_base)
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted on
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
set $create_action_seen = 0
|
||||||
|
set $manager_request_seen = 0
|
||||||
|
set $data_source_request_seen = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['action_handler']:x}
|
||||||
|
condition 1 $edx >= 0x{FIRST_SELECT_TEAM_ACTION:x} && $edx <= 0x{LAST_SELECT_TEAM_ACTION:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
if $edx == 0x{ACTION_CREATE_MATCH:x}
|
||||||
|
set $create_action_seen = 1
|
||||||
|
end
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d action=%#x is_create=%d controller=%p payload=%p create_seen=%d\\n", $_thread, $edx, $edx==0x{ACTION_CREATE_MATCH:x}, $rcx, $r8, $create_action_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['manager_request']:x}
|
||||||
|
condition 2 $edx == 0x{CREATE_DATA_SOURCE:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $manager_request_seen = 1
|
||||||
|
set $tree_sentinel = $rcx + 0x10
|
||||||
|
set $tree_cursor = *(void**)($rcx+0x20)
|
||||||
|
set $data_node = $tree_sentinel
|
||||||
|
while $tree_cursor != 0 && $tree_cursor != $tree_sentinel
|
||||||
|
if *(unsigned int*)($tree_cursor+0x20) >= 0x{CREATE_DATA_SOURCE:x}
|
||||||
|
set $data_node = $tree_cursor
|
||||||
|
set $tree_cursor = *(void**)($tree_cursor+0x08)
|
||||||
|
else
|
||||||
|
set $tree_cursor = *(void**)$tree_cursor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
set $data_source = 0
|
||||||
|
set $request_method = 0
|
||||||
|
if $data_node != $tree_sentinel && *(unsigned int*)($data_node+0x20) == 0x{CREATE_DATA_SOURCE:x}
|
||||||
|
set $data_source = *(void**)($data_node+0x28)
|
||||||
|
if $data_source != 0
|
||||||
|
set $request_method = *(void**)(*(void**)$data_source+0x18)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d MANAGER_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d source=%#x manager=%p request=%p node=%p data_source=%p request_method=%p create_seen=%d\\n", $_thread, $edx, $rcx, $r8, $data_node, $data_source, $request_method, $create_action_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['data_source_request']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $data_source_request_seen = 1
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x create_seen=%d manager_seen=%d\\n", $_thread, $rcx-0x50, $rcx, $rdx, *(unsigned char*)($rcx+0x38), $create_action_seen, $manager_request_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['ui_dispatch']:x}
|
||||||
|
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x payload=%p ui_manager=%p create_seen=%d manager_seen=%d data_source_seen=%d\\n", $_thread, $r8d, $rdx, $rcx, $create_action_seen, $manager_request_seen, $data_source_request_seen
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "ACTIONTRACE ARMED pid={pid} action_handler=0x{address['action_handler']:x} manager_request=0x{address['manager_request']:x} data_source_request=0x{address['data_source_request']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000, 0x140000000)
|
||||||
|
assert address == {
|
||||||
|
"action_handler": 0x1800BFCC0,
|
||||||
|
"manager_request": 0x1480D2340,
|
||||||
|
"data_source_request": 0x180120270,
|
||||||
|
"ui_dispatch": 0x1480D1070,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(
|
||||||
|
45949, 0x180000000, 0x140000000, "/tmp/create-action.log"
|
||||||
|
)
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edx == 0x{ACTION_CREATE_MATCH:x}" in script
|
||||||
|
assert f"$edx == 0x{CREATE_DATA_SOURCE:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "request_method" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_create_action_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or transition.find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||||
|
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-create-action-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-match-create-action-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17 provider delivery lookup without heap-address assumptions.
|
||||||
|
|
||||||
|
The probe anchors the real CardsDLL call sequence in FUN_1801a4cd0 and the
|
||||||
|
provider-specific FUT_CREATE_MATCH_DP readiness check in FUN_1800be500:
|
||||||
|
|
||||||
|
vslot +0x38 call -> create gate return -> returned target -> UI bridge
|
||||||
|
|
||||||
|
For FUT_CREATE_MATCH_DP and FUT_GET_MATCH_KITS_DP it records the live controller
|
||||||
|
vtable, concrete lookup function, event service, readiness-gate implementation,
|
||||||
|
every register input, returned target, and whether the native-to-UI bridge
|
||||||
|
executes. No post-event object identity is used.
|
||||||
|
|
||||||
|
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||||
|
It never writes client memory and never drives game input.
|
||||||
|
|
||||||
|
match_delivery_lifecycle_trace.py [pid] [--output PATH]
|
||||||
|
match_delivery_lifecycle_trace.py --print-script [pid]
|
||||||
|
match_delivery_lifecycle_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_advance_trace as advance
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
LOOKUP_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2C
|
||||||
|
LOOKUP_RETURN_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2F
|
||||||
|
BRIDGE_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x71
|
||||||
|
CREATE_GATE_RETURN_RVA = 0x0BE647
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"lookup_call": cards_base + LOOKUP_CALL_RVA,
|
||||||
|
"gate_return": cards_base + CREATE_GATE_RETURN_RVA,
|
||||||
|
"lookup_return": cards_base + LOOKUP_RETURN_RVA,
|
||||||
|
"bridge": cards_base + BRIDGE_CALL_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
address = trace_addresses(cards_base)
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted on
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
set $current_provider = 0
|
||||||
|
set $current_payload = 0
|
||||||
|
set $current_controller = 0
|
||||||
|
set $current_vtable = 0
|
||||||
|
set $current_lookup = 0
|
||||||
|
set $current_service = 0
|
||||||
|
set $current_service_vtable = 0
|
||||||
|
set $current_gate = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['lookup_call']:x}
|
||||||
|
condition 1 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $current_provider = $edi
|
||||||
|
set $current_payload = $rbp
|
||||||
|
set $current_controller = $rcx
|
||||||
|
set $current_vtable = *(void**)$rcx
|
||||||
|
set $current_lookup = *(void**)(*(void**)$rcx+0x38)
|
||||||
|
set $current_service = *(void**)($rcx+0x18)
|
||||||
|
set $current_service_vtable = *(void**)$current_service
|
||||||
|
set $current_gate = *(void**)($current_service_vtable+0x58)
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x this=%p outer_controller=%p payload=%p vtable=%p lookup_fn=%p service=%p service_vtable=%p gate_fn=%p controller_mode=%#x controller_flag=%#x rdx=%p r8=%p r9=%p state_rbx=%p state_rbp=%p\\n", $_thread, $edi, $rcx, $rbx, $rbp, $current_vtable, $current_lookup, $current_service, $current_service_vtable, $current_gate, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x152), $rdx, $r8, $r9, $rbx, $rbp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['gate_return']:x}
|
||||||
|
condition 2 $current_provider == 0x{transition.FUT_CREATE_MATCH_DP:x} && $rbx == $current_controller
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d CREATE_GATE_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x controller=%p service=%p service_vtable=%p gate_fn=%p selector=0x7546 result_al=%#x\\n", $_thread, $current_provider, $current_controller, $current_service, $current_service_vtable, $current_gate, $al
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['lookup_return']:x}
|
||||||
|
condition 3 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x controller=%p payload=%p vtable=%p lookup_fn=%p result=%p\\n", $_thread, $edi, $rbx, $rbp, $current_vtable, $current_lookup, $rax
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['bridge']:x}
|
||||||
|
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d provider=%#x controller=%p payload=%p target=%p bridge=%p callback=%p\\n", $_thread, $edi, $current_controller, $current_payload, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "LOOKUPTRACE ARMED pid={pid} lookup_call=0x{address['lookup_call']:x} gate_return=0x{address['gate_return']:x} lookup_return=0x{address['lookup_return']:x} bridge=0x{address['bridge']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000)
|
||||||
|
assert address == {
|
||||||
|
"lookup_call": 0x1801A4CFC,
|
||||||
|
"gate_return": 0x1800BE647,
|
||||||
|
"lookup_return": 0x1801A4CFF,
|
||||||
|
"bridge": 0x1801A4D41,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(35632, 0x180000000, "/tmp/lookup.log")
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edi == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "LOOKUP_CALL" in script
|
||||||
|
assert "CREATE_GATE_RETURN" in script
|
||||||
|
assert "LOOKUP_RETURN" in script
|
||||||
|
assert "gate_fn" in script
|
||||||
|
assert "BRIDGE" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_delivery_lifecycle_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or transition.find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||||
|
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-provider-lookup-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, cards_base, output)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-match-provider-lookup-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17's post-kit handoff into the gameplay loading state.
|
||||||
|
|
||||||
|
The probe anchors the second ACTION_SAVE_MATCH_KIT (0x7576), captures the
|
||||||
|
select-team deleting destructor with its real caller, records entry to the
|
||||||
|
Gameplay::ScenarioModeStart consumer with the state it would advance, and
|
||||||
|
identifies the first TestingGame update after the boundary.
|
||||||
|
|
||||||
|
The generated GDB program uses four hardware-assisted execution breakpoints.
|
||||||
|
It never writes client memory, calls client functions, drives input, emits
|
||||||
|
actions, or changes timing deliberately.
|
||||||
|
|
||||||
|
match_drill_transition_trace.py [pid] [--output PATH]
|
||||||
|
match_drill_transition_trace.py --print-script [pid]
|
||||||
|
match_drill_transition_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_advance_trace as advance
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
SAVE_KIT_ACTION = 0x7576
|
||||||
|
SAVE_ACTION_RVA = 0x0BFCC0
|
||||||
|
SELECT_TEAM_DELETING_DESTRUCTOR_RVA = 0x0BE020
|
||||||
|
TESTING_GAME_UPDATE_RVA = 0x05A410C8
|
||||||
|
SCENARIO_MODE_START_HANDLER_RVA = 0x05A58EC0
|
||||||
|
TESTING_GAME_VTABLE_RVA = 0x035C58A8
|
||||||
|
TESTING_GAME_STATE_VTABLE_RVA = 0x035C2EE0
|
||||||
|
|
||||||
|
OWNER_STATE_OFFSET = 0x1958
|
||||||
|
STATE_GAME_DATABASE_OFFSET = 0x17450
|
||||||
|
STATE_PHASE_OFFSET = 0x27BEC
|
||||||
|
STATE_SCENARIO_MODE_START_GATE_OFFSET = 0x359E8
|
||||||
|
DATABASE_IS_SKILL_GAME_OFFSET = 0x7382
|
||||||
|
DATABASE_TEAM_PAIR_OFFSET = 0x73C4
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"save_action": cards_base + SAVE_ACTION_RVA,
|
||||||
|
"deleting_destructor": cards_base + SELECT_TEAM_DELETING_DESTRUCTOR_RVA,
|
||||||
|
"testing_game_update": fifa_base + TESTING_GAME_UPDATE_RVA,
|
||||||
|
"scenario_mode_start_handler": fifa_base + SCENARIO_MODE_START_HANDLER_RVA,
|
||||||
|
"testing_game_vtable": fifa_base + TESTING_GAME_VTABLE_RVA,
|
||||||
|
"testing_game_state_vtable": fifa_base + TESTING_GAME_STATE_VTABLE_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(
|
||||||
|
pid: int,
|
||||||
|
cards_base: int,
|
||||||
|
fifa_base: int,
|
||||||
|
output: str,
|
||||||
|
) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
address = trace_addresses(cards_base, fifa_base)
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted on
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
set $save_count = 0
|
||||||
|
set $current_controller = 0
|
||||||
|
set $engine_seen = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['save_action']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
if $edx == 0x{SAVE_KIT_ACTION:x}
|
||||||
|
set $save_count = $save_count + 1
|
||||||
|
set $current_controller = $rcx
|
||||||
|
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SAVE_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, $save_count==2
|
||||||
|
if $save_count == 2
|
||||||
|
disable 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['deleting_destructor']:x}
|
||||||
|
condition 2 $save_count >= 2 && $rcx == $current_controller
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_DELETING_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d controller=%p delete_flags=%#x caller_return=%p vtable=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp, *(void**)$rcx
|
||||||
|
x/16gx $rsp
|
||||||
|
bt 12
|
||||||
|
disable 2
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['scenario_mode_start_handler']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $scenario_wrapper = $rcx
|
||||||
|
set $scenario_state = *(void**)($scenario_wrapper+0x30)
|
||||||
|
set $scenario_payload = $r9
|
||||||
|
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $scenario_state != 0
|
||||||
|
set $scenario_database = *(void**)($scenario_state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||||
|
if $scenario_database != 0
|
||||||
|
printf "thread=%d wrapper=%p state=%p payload=%p phase=%d alternate_gate=%d is_skill_game=%d caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(unsigned int*)($scenario_state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($scenario_state+0x{STATE_SCENARIO_MODE_START_GATE_OFFSET:x}), *(unsigned char*)($scenario_database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(void**)$rsp
|
||||||
|
else
|
||||||
|
printf "thread=%d wrapper=%p state=%p payload=%p database=0 caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(void**)$rsp
|
||||||
|
end
|
||||||
|
else
|
||||||
|
printf "thread=%d wrapper=%p state=0 payload=%p caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_payload, *(void**)$rsp
|
||||||
|
end
|
||||||
|
bt 12
|
||||||
|
disable 3
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['testing_game_update']:x}
|
||||||
|
condition 4 $save_count >= 2 && $engine_seen == 0
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $owner = $rsi
|
||||||
|
set $state = *(void**)($owner+0x{OWNER_STATE_OFFSET:x})
|
||||||
|
if $state != 0 && *(void**)$owner == 0x{address['testing_game_vtable']:x} && *(void**)$state == 0x{address['testing_game_state_vtable']:x}
|
||||||
|
set $database = *(void**)($state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||||
|
if $database != 0
|
||||||
|
set $engine_seen = 1
|
||||||
|
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d ENGINE_HANDOFF" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d owner=%p owner_vtable=%p state=%p state_vtable=%p database=%p phase=%d is_skill_game=%d teams=%d,%d\\n", $_thread, $owner, *(void**)$owner, $state, *(void**)$state, $database, *(unsigned int*)($state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET + 4:x})
|
||||||
|
bt 12
|
||||||
|
disable 4
|
||||||
|
end
|
||||||
|
end
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "DRILLTRACE ARMED pid={pid} save_action=0x{address['save_action']:x} deleting_destructor=0x{address['deleting_destructor']:x} scenario_mode_start_handler=0x{address['scenario_mode_start_handler']:x} testing_game_update=0x{address['testing_game_update']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000, 0x140000000)
|
||||||
|
assert address == {
|
||||||
|
"save_action": 0x1800BFCC0,
|
||||||
|
"deleting_destructor": 0x1800BE020,
|
||||||
|
"testing_game_update": 0x145A410C8,
|
||||||
|
"scenario_mode_start_handler": 0x145A58EC0,
|
||||||
|
"testing_game_vtable": 0x1435C58A8,
|
||||||
|
"testing_game_state_vtable": 0x1435C2EE0,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(
|
||||||
|
49938,
|
||||||
|
0x180000000,
|
||||||
|
0x140000000,
|
||||||
|
"/tmp/drill-transition.log",
|
||||||
|
)
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert "SELECT_TEAM_DELETING_DESTRUCTOR" in script
|
||||||
|
assert "SCENARIO_MODE_START" in script
|
||||||
|
assert "wrapper=%p state=%p payload=%p" in script
|
||||||
|
assert "alternate_gate=%d" in script
|
||||||
|
assert "skill_game_start_constructor" not in script
|
||||||
|
assert "ENGINE_HANDOFF" in script
|
||||||
|
assert "GameplayGameDatabase.IsSkillGame" not in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_drill_transition_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or transition.find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||||
|
advance.validate_file(
|
||||||
|
fifa_path,
|
||||||
|
advance.PINNED_FIFA_SHA256,
|
||||||
|
advance.FIFA_MODULE,
|
||||||
|
)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-drill-transition-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-match-drill-transition-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+185
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17's post-kit boundary without changing client behavior.
|
||||||
|
|
||||||
|
The probe anchors both ACTION_SAVE_MATCH_KIT (0x7576) actions, their concrete
|
||||||
|
native save call, the action-handler return, and select-team provider teardown.
|
||||||
|
The second 0x7576 action is the temporal boundary for later drill/game-loader
|
||||||
|
instrumentation.
|
||||||
|
|
||||||
|
The generated GDB program uses four hardware-assisted execution breakpoints. It
|
||||||
|
never writes client memory, calls client functions, drives input, emits actions,
|
||||||
|
or alters timing deliberately.
|
||||||
|
|
||||||
|
match_post_kit_trace.py [pid] [--output PATH]
|
||||||
|
match_post_kit_trace.py --print-script [pid]
|
||||||
|
match_post_kit_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_advance_trace as advance
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
SAVE_KIT_ACTION = 0x7576
|
||||||
|
ACTION_HANDLER_RVA = 0x0BFCC0
|
||||||
|
SAVE_CALL_RVA = 0x0BFF25
|
||||||
|
ACTION_RETURN_RVA = 0x0C00AE
|
||||||
|
SELECT_TEAM_DESTRUCTOR_RVA = 0x0BDEC0
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"action": cards_base + ACTION_HANDLER_RVA,
|
||||||
|
"save_call": cards_base + SAVE_CALL_RVA,
|
||||||
|
"action_return": cards_base + ACTION_RETURN_RVA,
|
||||||
|
"destructor": cards_base + SELECT_TEAM_DESTRUCTOR_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
address = trace_addresses(cards_base)
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted on
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
set $save_count = 0
|
||||||
|
set $current_action = 0
|
||||||
|
set $current_controller = 0
|
||||||
|
set $current_payload = 0
|
||||||
|
set $second_save_epoch = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['action']:x}
|
||||||
|
condition 1 $edx == 0x{SAVE_KIT_ACTION:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $save_count = $save_count + 1
|
||||||
|
set $current_action = $edx
|
||||||
|
set $current_controller = $rcx
|
||||||
|
set $current_payload = $r8
|
||||||
|
python import time, gdb; now = time.time_ns(); gdb.set_convenience_variable("event_epoch", now); print("POSTKIT epoch_ns=%d mono_ns=%d SAVE_ACTION" % (now, time.monotonic_ns()), end=" ")
|
||||||
|
if $save_count == 2
|
||||||
|
set $second_save_epoch = $event_epoch
|
||||||
|
end
|
||||||
|
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p payload_vtable=%p mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, *(void**)$r8, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x150), *(unsigned char*)($rcx+0x151), *(unsigned char*)($rcx+0x152), *(unsigned char*)($rcx+0x155), $save_count==2
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['save_call']:x}
|
||||||
|
condition 2 $current_action == 0x{SAVE_KIT_ACTION:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $save_target = *(void**)(*(void**)$rcx+0x1d0)
|
||||||
|
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d NATIVE_SAVE_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d ordinal=%d central=%p central_vtable=%p target=%p side=%#x request=%p payload=%p\\n", $_thread, $save_count, $rcx, *(void**)$rcx, $save_target, $r8d, $rdx, $current_payload
|
||||||
|
x/12gx $rdx
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['action_return']:x}
|
||||||
|
condition 3 $current_action == 0x{SAVE_KIT_ACTION:x} && $rsi == $current_controller
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d ACTION_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d ordinal=%d controller=%p handled=%#x mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x\\n", $_thread, $save_count, $rsi, $al, *(unsigned int*)($rsi+0x140), *(unsigned char*)($rsi+0x150), *(unsigned char*)($rsi+0x151), *(unsigned char*)($rsi+0x152), *(unsigned char*)($rsi+0x155)
|
||||||
|
set $current_action = 0
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['destructor']:x}
|
||||||
|
condition 4 $save_count >= 2 && $rcx == $current_controller
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d SELECT_TEAM_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d controller=%p save_count=%d second_save_epoch=%lld vtable=%p mode=%#x\\n", $_thread, $rcx, $save_count, $second_save_epoch, *(void**)$rcx, *(unsigned int*)($rcx+0x140)
|
||||||
|
bt 12
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "POSTKIT ARMED pid={pid} action=0x{address['action']:x} save_call=0x{address['save_call']:x} action_return=0x{address['action_return']:x} destructor=0x{address['destructor']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000)
|
||||||
|
assert address == {
|
||||||
|
"action": 0x1800BFCC0,
|
||||||
|
"save_call": 0x1800BFF25,
|
||||||
|
"action_return": 0x1800C00AE,
|
||||||
|
"destructor": 0x1800BDEC0,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(47872, 0x180000000, "/tmp/post-kit.log")
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edx == 0x{SAVE_KIT_ACTION:x}" in script
|
||||||
|
assert "second_boundary" in script
|
||||||
|
assert "NATIVE_SAVE_CALL" in script
|
||||||
|
assert "SELECT_TEAM_DESTRUCTOR" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_post_kit_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or transition.find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||||
|
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-post-kit-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, cards_base, output)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-match-post-kit-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17 create-response readiness versus UI provider dispatch.
|
||||||
|
|
||||||
|
The probe correlates four concrete lifecycle boundaries:
|
||||||
|
|
||||||
|
* FutCreateMatchServerResponse data-source request;
|
||||||
|
* the POST /match network response callback;
|
||||||
|
* the response readiness/completion callback;
|
||||||
|
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||||
|
|
||||||
|
This distinguishes network completion from the separate DataManager readiness
|
||||||
|
lifecycle without assuming any screen or heap-object identity. The generated GDB
|
||||||
|
program uses hardware-assisted execution breakpoints only. It never writes
|
||||||
|
client memory and never drives game input.
|
||||||
|
|
||||||
|
match_provider_producer_trace.py [pid] [--output PATH]
|
||||||
|
match_provider_producer_trace.py --print-script [pid]
|
||||||
|
match_provider_producer_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_advance_trace as advance
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||||
|
NETWORK_RESPONSE_RVA = transition.RESPONSE_CALLBACK_RVA
|
||||||
|
CREATE_COMPLETE_RVA = 0x120000
|
||||||
|
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||||
|
CREATE_RESPONSE_OFFSET = 0xA0
|
||||||
|
CREATE_DATA_SOURCE_OFFSET = CREATE_RESPONSE_OFFSET + 0x50
|
||||||
|
CREATE_READY_OFFSET = CREATE_RESPONSE_OFFSET + 0x88
|
||||||
|
ACTIVE_CALLBACK_OFFSET = 0x47D0
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||||
|
"network_response": cards_base + NETWORK_RESPONSE_RVA,
|
||||||
|
"create_complete": cards_base + CREATE_COMPLETE_RVA,
|
||||||
|
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(
|
||||||
|
pid: int, cards_base: int, fifa_base: int, output: str
|
||||||
|
) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
address = trace_addresses(cards_base, fifa_base)
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted on
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
set $last_central = 0
|
||||||
|
set $last_response = 0
|
||||||
|
set $last_data_source = 0
|
||||||
|
set $last_descriptor = 0
|
||||||
|
|
||||||
|
hbreak *0x{address['data_source_request']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $request_data_source = $rcx
|
||||||
|
set $request_response = $rcx - 0x50
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x callback_adapter=%p callback_context=%p callback_target=%p\\n", $_thread, $request_response, $request_data_source, $rdx, *(unsigned char*)($request_data_source+0x38), *(void**)($request_response+0x90), *(void**)($request_response+0x98), *(void**)($request_response+0xa0)
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['network_response']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $last_central = $rcx
|
||||||
|
set $last_response = $rcx + 0x{CREATE_RESPONSE_OFFSET:x}
|
||||||
|
set $last_data_source = $rcx + 0x{CREATE_DATA_SOURCE_OFFSET:x}
|
||||||
|
set $last_descriptor = $rdx
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d NETWORK_RESPONSE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $rdx == 0
|
||||||
|
printf "thread=%d central=%p descriptor=(nil) status=UNKNOWN wire_payload=(nil) response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||||
|
else
|
||||||
|
printf "thread=%d central=%p descriptor=%p status=%#x wire_payload=%p response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||||
|
end
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['create_complete']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d CREATE_COMPLETE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $rdx == 0
|
||||||
|
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=(nil) status=UNKNOWN last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $last_response, $rcx==$last_response
|
||||||
|
else
|
||||||
|
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=%p status=%#x last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $rdx, *(unsigned int*)($rdx+0x1c), $last_response, $rcx==$last_response
|
||||||
|
end
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['ui_dispatch']:x}
|
||||||
|
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $last_response == 0
|
||||||
|
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=(nil) ready=UNKNOWN\\n", $_thread, $r8d, $rdx, $rcx
|
||||||
|
else
|
||||||
|
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=%p data_source=%p ready=%#x descriptor=%p\\n", $_thread, $r8d, $rdx, $rcx, $last_response, $last_data_source, *(unsigned char*)($last_response+0x88), $last_descriptor
|
||||||
|
end
|
||||||
|
bt 10
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "RESPTRACE ARMED pid={pid} data_source_request=0x{address['data_source_request']:x} network_response=0x{address['network_response']:x} create_complete=0x{address['create_complete']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
address = trace_addresses(0x180000000, 0x140000000)
|
||||||
|
assert address == {
|
||||||
|
"data_source_request": 0x180120270,
|
||||||
|
"network_response": 0x180114D90,
|
||||||
|
"create_complete": 0x180120000,
|
||||||
|
"ui_dispatch": 0x1480D1070,
|
||||||
|
}
|
||||||
|
script = build_gdb_script(
|
||||||
|
38872, 0x180000000, 0x140000000, "/tmp/response-lifecycle.log"
|
||||||
|
)
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert "DATA_SOURCE_REQUEST" in script
|
||||||
|
assert "NETWORK_RESPONSE" in script
|
||||||
|
assert "CREATE_COMPLETE" in script
|
||||||
|
assert "UI_DISPATCH" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_provider_producer_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or transition.find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||||
|
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-response-lifecycle-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-match-response-lifecycle-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+233
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace the FIFA17 create-match publish boundary with hardware breakpoints.
|
||||||
|
|
||||||
|
The tracer covers the client-local path after POST /match:
|
||||||
|
|
||||||
|
response callback -> deserializer -> controller event 0x7546
|
||||||
|
-> FUT_CREATE_MATCH_DP 0x7563
|
||||||
|
|
||||||
|
FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the
|
||||||
|
same native dispatcher. The generated GDB program uses only hardware-assisted
|
||||||
|
execution breakpoints. It never writes client memory and never drives game
|
||||||
|
input.
|
||||||
|
|
||||||
|
match_transition_trace.py [pid] [--output PATH]
|
||||||
|
match_transition_trace.py --print-script [pid]
|
||||||
|
match_transition_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_MODULE = "CardsDLL_Win64_retail.dll"
|
||||||
|
PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c"
|
||||||
|
RESPONSE_CALLBACK_RVA = 0x114D90
|
||||||
|
DESERIALIZE_SUCCESS_RVA = 0x118940
|
||||||
|
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||||
|
PROVIDER_DISPATCH_RVA = 0x1A4CD0
|
||||||
|
CREATE_MATCH_CONTROLLER_EVENT = 0x7546
|
||||||
|
FUT_CREATE_MATCH_DP = 0x7563
|
||||||
|
FUT_GET_MATCH_KITS_DP = 0x7565
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid() -> int | None:
|
||||||
|
found = []
|
||||||
|
for directory in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(directory, "comm"), encoding="utf-8") as handle:
|
||||||
|
if handle.read().strip() != "FIFA17.exe":
|
||||||
|
continue
|
||||||
|
pid = int(os.path.basename(directory))
|
||||||
|
with open(os.path.join(directory, "statm"), encoding="utf-8") as handle:
|
||||||
|
resident_pages = int(handle.read().split()[1])
|
||||||
|
found.append((resident_pages, pid))
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
return max(found)[1] if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cards_mapping(lines) -> tuple[int, str]:
|
||||||
|
for line in lines:
|
||||||
|
fields = line.split(maxsplit=5)
|
||||||
|
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||||
|
if not path.endswith(CARDS_MODULE):
|
||||||
|
continue
|
||||||
|
start = int(fields[0].split("-", 1)[0], 16)
|
||||||
|
return start, path
|
||||||
|
raise RuntimeError(f"{CARDS_MODULE} is not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def cards_mapping(pid: int) -> tuple[int, str]:
|
||||||
|
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||||
|
try:
|
||||||
|
return parse_cards_mapping(handle)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise RuntimeError(f"{error} in PID {pid}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: str) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cards(path: str) -> None:
|
||||||
|
actual = sha256_file(path)
|
||||||
|
if actual != PINNED_CARDS_SHA256:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trace_addresses(base: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"response": base + RESPONSE_CALLBACK_RVA,
|
||||||
|
"deserialize": base + DESERIALIZE_SUCCESS_RVA,
|
||||||
|
"controller": base + CREATE_MATCH_CONTROLLER_RVA,
|
||||||
|
"provider": base + PROVIDER_DISPATCH_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_gdb_script(pid: int, base: int, output: str) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
address = trace_addresses(base)
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted on
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
|
||||||
|
hbreak *0x{address['response']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
if $rdx != 0
|
||||||
|
printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp
|
||||||
|
else
|
||||||
|
printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp
|
||||||
|
end
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['deserialize']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['controller']:x}
|
||||||
|
condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['provider']:x}
|
||||||
|
condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
base = 0x180000000
|
||||||
|
address = trace_addresses(base)
|
||||||
|
assert address == {
|
||||||
|
"response": 0x180114D90,
|
||||||
|
"deserialize": 0x180118940,
|
||||||
|
"controller": 0x1800BF950,
|
||||||
|
"provider": 0x1801A4CD0,
|
||||||
|
}
|
||||||
|
mapping = parse_cards_mapping(
|
||||||
|
[
|
||||||
|
"6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 "
|
||||||
|
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert mapping == (
|
||||||
|
0x6FFFFC0F0000,
|
||||||
|
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll",
|
||||||
|
)
|
||||||
|
script = build_gdb_script(25718, base, "/tmp/match-transition.log")
|
||||||
|
assert script.count("hbreak *") == 4
|
||||||
|
assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script
|
||||||
|
assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script
|
||||||
|
assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script
|
||||||
|
assert "T3_RESPONSE_CALLBACK" in script
|
||||||
|
assert "T4_DESERIALIZE_SUCCESS" in script
|
||||||
|
assert "T5_CREATE_MATCH_CONTROLLER" in script
|
||||||
|
assert "T6_PROVIDER_DISPATCH" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("match_transition_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
base, cards_path = cards_mapping(pid)
|
||||||
|
validate_cards(cards_path)
|
||||||
|
output = args.output or f"/tmp/fifa17-match-transition-{pid}.log"
|
||||||
|
script = build_gdb_script(pid, base, output)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-match-transition-{pid}.gdb"
|
||||||
|
with open(script_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(script)
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+306
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only dynamic locator for FIFA17 Offline Seasons match state.
|
||||||
|
|
||||||
|
Never relies on heap addresses or allocator handles. It identifies:
|
||||||
|
|
||||||
|
* the 10 x 16-byte parsed fixture array from its complete wire-derived record
|
||||||
|
sequence (teamId/difficulty/roundId/rewardMult/coins),
|
||||||
|
* match-team records from the corrected invariant prefix (11,7,0,0,76), never
|
||||||
|
from the transient +0x18 handle,
|
||||||
|
* the match-config team pair from structural fields around it, not its team ids.
|
||||||
|
|
||||||
|
offline_match_locator.py [pid] [--fixture-index 0] [--json]
|
||||||
|
offline_match_locator.py --selftest
|
||||||
|
|
||||||
|
READ-ONLY: /proc/<pid>/mem is opened 'rb'. No debugger and no game input.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
DEFAULT_TEAMS = (73, 240, 241, 243, 73, 240, 241, 243, 73, 240)
|
||||||
|
MATCH_HEADER = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||||
|
PARTICIPANT_PREFIX = struct.pack("<8i", -1, -2, -1, -2, -1, -2, -1, -2)
|
||||||
|
F01 = 0x3DCCCCCD
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Fixture:
|
||||||
|
address: int
|
||||||
|
selected_address: int
|
||||||
|
selected_index: int
|
||||||
|
selected_team_id: int
|
||||||
|
records: list[dict[str, int]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchTeam:
|
||||||
|
address: int
|
||||||
|
team_id: int
|
||||||
|
marker_18: int
|
||||||
|
marker_1c: int
|
||||||
|
xi: list[int]
|
||||||
|
substitutes: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchConfig:
|
||||||
|
pair_address: int
|
||||||
|
team_id_0: int
|
||||||
|
team_id_1: int
|
||||||
|
player_count_0: int
|
||||||
|
player_count_1: int
|
||||||
|
|
||||||
|
|
||||||
|
def find_pids() -> list[int]:
|
||||||
|
"""All live FIFA17.exe processes, largest resident set first.
|
||||||
|
|
||||||
|
The UMU/Proton launch chain briefly creates a small process with the same
|
||||||
|
comm before the real game. Returning the first /proc glob match attached
|
||||||
|
the trace supervisor to that short-lived process and missed the match.
|
||||||
|
"""
|
||||||
|
found = []
|
||||||
|
for directory in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(directory, "comm")) as handle:
|
||||||
|
if handle.read().strip() != "FIFA17.exe":
|
||||||
|
continue
|
||||||
|
pid = int(os.path.basename(directory))
|
||||||
|
with open(os.path.join(directory, "statm")) as handle:
|
||||||
|
resident_pages = int(handle.read().split()[1])
|
||||||
|
found.append((resident_pages, pid))
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
return [pid for _resident, pid in sorted(found, reverse=True)]
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid() -> int | None:
|
||||||
|
pids = find_pids()
|
||||||
|
return pids[0] if pids else None
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_bytes(teams: tuple[int, ...] = DEFAULT_TEAMS) -> bytes:
|
||||||
|
return b"".join(
|
||||||
|
struct.pack("<iBBHii", team_id, 1, round_id, 0, 1, 400)
|
||||||
|
for round_id, team_id in enumerate(teams)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def readable_regions(pid: int, *, writable_anon_only: bool = False):
|
||||||
|
with open(f"/proc/{pid}/maps") as maps:
|
||||||
|
for line in maps:
|
||||||
|
match = re.match(
|
||||||
|
r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
lo, hi = int(match.group(1), 16), int(match.group(2), 16)
|
||||||
|
perms, path = match.group(3), match.group(4).strip()
|
||||||
|
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||||
|
continue
|
||||||
|
if hi - lo > 512 * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
if writable_anon_only and (perms[1] != "w" or path):
|
||||||
|
continue
|
||||||
|
yield lo, hi, perms, path
|
||||||
|
|
||||||
|
|
||||||
|
def _i32(buf: bytes, offset: int) -> int:
|
||||||
|
return struct.unpack_from("<i", buf, offset)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_fixture_buffer(buf: bytes, base: int, selected_index: int) -> list[Fixture]:
|
||||||
|
pattern = fixture_bytes()
|
||||||
|
found = []
|
||||||
|
offset = buf.find(pattern)
|
||||||
|
while offset >= 0:
|
||||||
|
records = []
|
||||||
|
for round_id in range(len(DEFAULT_TEAMS)):
|
||||||
|
at = offset + round_id * 16
|
||||||
|
team_id, difficulty, parsed_round, _pad, reward_mult, coins = struct.unpack_from(
|
||||||
|
"<iBBHii", buf, at
|
||||||
|
)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"team_id": team_id,
|
||||||
|
"difficulty": difficulty,
|
||||||
|
"round_id": parsed_round,
|
||||||
|
"reward_mult": reward_mult,
|
||||||
|
"coins": coins,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
found.append(
|
||||||
|
Fixture(
|
||||||
|
address=base + offset,
|
||||||
|
selected_address=base + offset + selected_index * 16,
|
||||||
|
selected_index=selected_index,
|
||||||
|
selected_team_id=records[selected_index]["team_id"],
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
offset = buf.find(pattern, offset + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def scan_match_team_buffer(buf: bytes, base: int) -> list[MatchTeam]:
|
||||||
|
found = []
|
||||||
|
offset = buf.find(MATCH_HEADER)
|
||||||
|
while offset >= 0:
|
||||||
|
if offset + 0x7C <= len(buf):
|
||||||
|
found.append(
|
||||||
|
MatchTeam(
|
||||||
|
address=base + offset,
|
||||||
|
team_id=_i32(buf, offset + 0x14),
|
||||||
|
marker_18=_i32(buf, offset + 0x18),
|
||||||
|
marker_1c=_i32(buf, offset + 0x1C),
|
||||||
|
xi=list(struct.unpack_from("<11i", buf, offset + 0x20)),
|
||||||
|
substitutes=list(struct.unpack_from("<12i", buf, offset + 0x4C)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
offset = buf.find(MATCH_HEADER, offset + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_config(buf: bytes, pair: int) -> bool:
|
||||||
|
required = pair + 0x50
|
||||||
|
if pair < 0 or required > len(buf):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
tuple(struct.unpack_from("<4I", buf, pair + 0x1C)) == (F01, F01, F01, F01)
|
||||||
|
and _i32(buf, pair + 0x38) == 11
|
||||||
|
and _i32(buf, pair + 0x3C) == 11
|
||||||
|
and _i32(buf, pair + 0x40) == 0
|
||||||
|
and _i32(buf, pair + 0x44) == 5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_match_config_buffer(buf: bytes, base: int) -> list[MatchConfig]:
|
||||||
|
found = []
|
||||||
|
offset = buf.find(PARTICIPANT_PREFIX)
|
||||||
|
while offset >= 0:
|
||||||
|
pair = offset + len(PARTICIPANT_PREFIX)
|
||||||
|
if _valid_config(buf, pair):
|
||||||
|
found.append(
|
||||||
|
MatchConfig(
|
||||||
|
pair_address=base + pair,
|
||||||
|
team_id_0=_i32(buf, pair),
|
||||||
|
team_id_1=_i32(buf, pair + 4),
|
||||||
|
player_count_0=_i32(buf, pair + 0x38),
|
||||||
|
player_count_1=_i32(buf, pair + 0x3C),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
offset = buf.find(PARTICIPANT_PREFIX, offset + 4)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def scan_process(
|
||||||
|
pid: int,
|
||||||
|
selected_index: int,
|
||||||
|
*,
|
||||||
|
include_fixture: bool = True,
|
||||||
|
writable_anon_only: bool = False,
|
||||||
|
) -> dict[str, list]:
|
||||||
|
result: dict[str, list] = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||||
|
with open(f"/proc/{pid}/mem", "rb", 0) as memory:
|
||||||
|
for lo, hi, _perms, _path in readable_regions(
|
||||||
|
pid, writable_anon_only=writable_anon_only
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
memory.seek(lo)
|
||||||
|
buf = memory.read(hi - lo)
|
||||||
|
except (OSError, ValueError, OverflowError):
|
||||||
|
continue
|
||||||
|
if include_fixture:
|
||||||
|
result["fixtures"].extend(scan_fixture_buffer(buf, lo, selected_index))
|
||||||
|
result["match_teams"].extend(scan_match_team_buffer(buf, lo))
|
||||||
|
result["match_configs"].extend(scan_match_config_buffer(buf, lo))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
fixture = fixture_bytes()
|
||||||
|
team = bytearray(0x7C)
|
||||||
|
team[:20] = MATCH_HEADER
|
||||||
|
struct.pack_into("<iii", team, 0x14, 130000, 0x54001, 0x54002)
|
||||||
|
struct.pack_into("<11i", team, 0x20, *range(11))
|
||||||
|
struct.pack_into("<12i", team, 0x4C, *range(20, 32))
|
||||||
|
config = bytearray(0x20 + 0x50)
|
||||||
|
config[:0x20] = PARTICIPANT_PREFIX
|
||||||
|
pair = 0x20
|
||||||
|
struct.pack_into("<ii", config, pair, 130000, 130000)
|
||||||
|
struct.pack_into("<4I", config, pair + 0x1C, F01, F01, F01, F01)
|
||||||
|
struct.pack_into("<iiii", config, pair + 0x38, 11, 11, 0, 5)
|
||||||
|
buf = b"X" * 32 + fixture + b"Y" * 32 + team + b"Z" * 32 + config
|
||||||
|
fixtures = scan_fixture_buffer(buf, 0x1000, 0)
|
||||||
|
teams = scan_match_team_buffer(buf, 0x1000)
|
||||||
|
configs = scan_match_config_buffer(buf, 0x1000)
|
||||||
|
assert len(fixtures) == 1 and fixtures[0].selected_team_id == 73
|
||||||
|
assert len(teams) == 1 and teams[0].team_id == 130000
|
||||||
|
assert len(configs) == 1 and configs[0].team_id_1 == 130000
|
||||||
|
# The transient handle is never part of the anchor.
|
||||||
|
struct.pack_into("<i", team, 0x18, -1)
|
||||||
|
assert len(scan_match_team_buffer(bytes(team), 0)) == 1
|
||||||
|
print("offline_match_locator selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--fixture-index", type=int, default=0)
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
parser.add_argument("--writable-anon-only", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
pid = args.pid or find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not 0 <= args.fixture_index < len(DEFAULT_TEAMS):
|
||||||
|
print("--fixture-index must be 0..9", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
result = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
writable_anon_only=args.writable_anon_only,
|
||||||
|
)
|
||||||
|
serial = {key: [asdict(value) for value in values] for key, values in result.items()}
|
||||||
|
serial["pid"] = pid
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(serial, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
print(f"pid={pid}")
|
||||||
|
for fixture in result["fixtures"]:
|
||||||
|
print(
|
||||||
|
f"fixture @0x{fixture.address:x}; selected index {fixture.selected_index} "
|
||||||
|
f"@0x{fixture.selected_address:x} teamId={fixture.selected_team_id}"
|
||||||
|
)
|
||||||
|
for config in result["match_configs"]:
|
||||||
|
print(
|
||||||
|
f"match config pair @0x{config.pair_address:x}: "
|
||||||
|
f"[{config.team_id_0}, {config.team_id_1}]"
|
||||||
|
)
|
||||||
|
for team in result["match_teams"]:
|
||||||
|
print(
|
||||||
|
f"match team @0x{team.address:x}: teamId={team.team_id} "
|
||||||
|
f"handles=[{team.marker_18}, {team.marker_1c}]"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"counts: fixtures={len(result['fixtures'])} "
|
||||||
|
f"configs={len(result['match_configs'])} teams={len(result['match_teams'])}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+394
@@ -0,0 +1,394 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain.
|
||||||
|
|
||||||
|
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||||
|
client memory, logs, and continues. Breakpoints are rotated so no more than four
|
||||||
|
are enabled. It never calls client functions, writes client memory, emits an
|
||||||
|
event, or drives input.
|
||||||
|
|
||||||
|
pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH]
|
||||||
|
pma_producer_trace.py --selftest
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import match_advance_trace as advance
|
||||||
|
import match_transition_trace as transition
|
||||||
|
|
||||||
|
VARIANTS = {
|
||||||
|
"mode0": {
|
||||||
|
"scenario_rva": 0x07B1C190,
|
||||||
|
"writer_rva": 0x07B1C26B,
|
||||||
|
"register_rva": 0x07B1C282,
|
||||||
|
"writer_context": "$rsi",
|
||||||
|
"writer_async_requested": "1",
|
||||||
|
"arm_condition": "1",
|
||||||
|
},
|
||||||
|
"alternate": {
|
||||||
|
"scenario_rva": 0x07B1C050,
|
||||||
|
"writer_rva": 0x07B1C12F,
|
||||||
|
"register_rva": 0x07B1C146,
|
||||||
|
"writer_context": "$rbp",
|
||||||
|
"writer_async_requested": "$sil",
|
||||||
|
"arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
PMA_COMPLETION_ARM_RVA = 0x07B1AE60
|
||||||
|
PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33
|
||||||
|
ASYNC_COMPLETION_RVA = 0x07B046C0
|
||||||
|
CALLBACK_DISPATCHER_RVA = 0x07AC87B0
|
||||||
|
PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0
|
||||||
|
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||||
|
PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750
|
||||||
|
|
||||||
|
|
||||||
|
def addresses(base: int, variant: str) -> dict[str, int]:
|
||||||
|
config = VARIANTS[variant]
|
||||||
|
return {
|
||||||
|
"scenario": base + config["scenario_rva"],
|
||||||
|
"writer": base + config["writer_rva"],
|
||||||
|
"register": base + config["register_rva"],
|
||||||
|
"arm": base + PMA_COMPLETION_ARM_RVA,
|
||||||
|
"arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA,
|
||||||
|
"completion": base + ASYNC_COMPLETION_RVA,
|
||||||
|
"dispatcher": base + CALLBACK_DISPATCHER_RVA,
|
||||||
|
"instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA,
|
||||||
|
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||||
|
"instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gdb_prelude(pid: int, output: str) -> str:
|
||||||
|
if any(character in output for character in "\n\r"):
|
||||||
|
raise ValueError("output path cannot contain a newline")
|
||||||
|
return f"""set pagination off
|
||||||
|
set confirm off
|
||||||
|
set print thread-events off
|
||||||
|
set breakpoint always-inserted off
|
||||||
|
set logging file {output}
|
||||||
|
set logging overwrite on
|
||||||
|
set logging redirect off
|
||||||
|
set logging enabled on
|
||||||
|
handle SIGSEGV nostop noprint pass
|
||||||
|
handle SIGILL nostop noprint pass
|
||||||
|
handle SIGFPE nostop noprint pass
|
||||||
|
handle SIGPIPE nostop noprint pass
|
||||||
|
handle SIGALRM nostop noprint pass
|
||||||
|
handle SIGUSR1 nostop noprint pass
|
||||||
|
handle SIGUSR2 nostop noprint pass
|
||||||
|
|
||||||
|
attach {pid}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str:
|
||||||
|
address = addresses(fifa_base, variant)
|
||||||
|
config = VARIANTS[variant]
|
||||||
|
return (
|
||||||
|
gdb_prelude(pid, output)
|
||||||
|
+ f"""define snapshot_pma_context
|
||||||
|
set $snap_ctx = $arg0
|
||||||
|
set $snap_flag40 = -1
|
||||||
|
set $snap_callback_vtable = 0
|
||||||
|
set $snap_callback_owner = 0
|
||||||
|
set $snap_dispatcher = 0
|
||||||
|
set $snap_dispatcher_vtable = 0
|
||||||
|
set $snap_pma = 0
|
||||||
|
set $snap_pma_flag18 = -1
|
||||||
|
set $snap_pma_parent = 0
|
||||||
|
set $snap_pma_machine = 0
|
||||||
|
set $snap_pma_current = 0
|
||||||
|
if $snap_ctx != 0
|
||||||
|
set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40)
|
||||||
|
set $snap_callback_vtable = *(void**)($snap_ctx+0x48)
|
||||||
|
set $snap_callback_owner = *(void**)($snap_ctx+0x78)
|
||||||
|
set $snap_dispatcher = $snap_ctx+0x80
|
||||||
|
set $snap_dispatcher_vtable = *(void**)$snap_dispatcher
|
||||||
|
set $snap_sentinel = $snap_ctx+0x88
|
||||||
|
set $snap_node = *(void**)$snap_sentinel
|
||||||
|
set $snap_scan = 0
|
||||||
|
while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8
|
||||||
|
set $snap_candidate = *(void**)($snap_node+0x10)
|
||||||
|
if $snap_candidate != 0
|
||||||
|
if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x}
|
||||||
|
set $snap_pma = $snap_candidate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
set $snap_node = *(void**)$snap_node
|
||||||
|
set $snap_scan = $snap_scan+1
|
||||||
|
end
|
||||||
|
if $snap_pma != 0
|
||||||
|
set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18)
|
||||||
|
set $snap_pma_parent = *(void**)($snap_pma+0x8)
|
||||||
|
if $snap_pma_parent != 0
|
||||||
|
set $snap_pma_machine = *(void**)($snap_pma_parent+0x8)
|
||||||
|
end
|
||||||
|
if $snap_pma_machine != 0
|
||||||
|
set $snap_pma_current = *(void**)($snap_pma_machine+0x10)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
define snapshot_gameplay
|
||||||
|
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
|
||||||
|
set $snap_listener_manager = 0
|
||||||
|
set $snap_listener_table = 0
|
||||||
|
set $snap_listener_index = -1
|
||||||
|
set $snap_free_roam = 0
|
||||||
|
set $snap_free_roam_state = -1
|
||||||
|
set $snap_free_roam_111 = -1
|
||||||
|
set $snap_free_roam_112 = -1
|
||||||
|
set $snap_free_roam_124 = -1
|
||||||
|
set $snap_selected = 0
|
||||||
|
set $snap_selected_vtable = 0
|
||||||
|
set $snap_selected_mode = -1
|
||||||
|
if $snap_gameplay_global != 0
|
||||||
|
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
|
||||||
|
end
|
||||||
|
if $snap_listener_manager != 0
|
||||||
|
set $snap_listener_table = *(void**)$snap_listener_manager
|
||||||
|
end
|
||||||
|
if $snap_listener_table != 0
|
||||||
|
set $snap_free_roam = *(void**)$snap_listener_table
|
||||||
|
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
|
||||||
|
if $snap_listener_index >= 0 && $snap_listener_index < 3
|
||||||
|
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if $snap_free_roam != 0
|
||||||
|
set $snap_free_roam_state = *(int*)($snap_free_roam+0x30)
|
||||||
|
set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||||
|
set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||||
|
set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124)
|
||||||
|
end
|
||||||
|
if $snap_selected != 0
|
||||||
|
set $snap_selected_vtable = *(void**)$snap_selected
|
||||||
|
set $snap_selected_mode = *(int*)($snap_selected+0x18)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
set $tracked_ctx = 0
|
||||||
|
|
||||||
|
|
||||||
|
hbreak *0x{address['scenario']:x}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $ctx = $rcx
|
||||||
|
set $tracked_ctx = $ctx
|
||||||
|
snapshot_pma_context $ctx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 1
|
||||||
|
enable 2
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['writer']:x}
|
||||||
|
condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx
|
||||||
|
disable 2
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $ctx = {config['writer_context']}
|
||||||
|
snapshot_pma_context $ctx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 2
|
||||||
|
enable 3
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['register']:x}
|
||||||
|
condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48
|
||||||
|
disable 3
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $callback = $rdx
|
||||||
|
set $ctx = $callback-0x48
|
||||||
|
snapshot_pma_context $ctx
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable
|
||||||
|
disable 3
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['arm']:x}
|
||||||
|
condition 4 {config['arm_condition']}
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $ctx = $rcx
|
||||||
|
if $tracked_ctx == 0
|
||||||
|
set $tracked_ctx = $ctx
|
||||||
|
end
|
||||||
|
snapshot_pma_context $ctx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 4
|
||||||
|
enable 5
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['arm_writer']:x}
|
||||||
|
condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx
|
||||||
|
disable 5
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $ctx = $rsi
|
||||||
|
snapshot_pma_context $ctx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 5
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['completion']:x}
|
||||||
|
condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $callback = $rcx
|
||||||
|
set $ctx = *(void**)($callback+0x30)
|
||||||
|
snapshot_pma_context $ctx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['dispatcher']:x}
|
||||||
|
condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $ctx = $rcx-0x80
|
||||||
|
snapshot_pma_context $ctx
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 7
|
||||||
|
enable 8
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
hbreak *0x{address['instructions']:x}
|
||||||
|
disable 8
|
||||||
|
commands
|
||||||
|
silent
|
||||||
|
set $listener = $rcx
|
||||||
|
set $parent = *(void**)($listener+0x8)
|
||||||
|
set $machine = 0
|
||||||
|
set $current = 0
|
||||||
|
if $parent != 0
|
||||||
|
set $machine = *(void**)($parent+0x8)
|
||||||
|
end
|
||||||
|
if $machine != 0
|
||||||
|
set $current = *(void**)($machine+0x10)
|
||||||
|
end
|
||||||
|
snapshot_gameplay
|
||||||
|
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||||
|
printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||||
|
disable 6
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n"
|
||||||
|
continue
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_environment(pid: int) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
|
||||||
|
if not item.startswith(b"OPENFUT_FIFA17_"):
|
||||||
|
continue
|
||||||
|
key, _, value = item.decode("utf-8", errors="replace").partition("=")
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
mode0 = addresses(0x140000000, "mode0")
|
||||||
|
alternate = addresses(0x140000000, "alternate")
|
||||||
|
script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0")
|
||||||
|
assert mode0["scenario"] == 0x147B1C190
|
||||||
|
assert mode0["writer"] == 0x147B1C26B
|
||||||
|
assert mode0["register"] == 0x147B1C282
|
||||||
|
assert alternate["scenario"] == 0x147B1C050
|
||||||
|
assert alternate["writer"] == 0x147B1C12F
|
||||||
|
assert alternate["register"] == 0x147B1C146
|
||||||
|
assert mode0["completion"] == 0x147B046C0
|
||||||
|
assert mode0["dispatcher"] == 0x147AC87B0
|
||||||
|
assert mode0["instructions"] == 0x147AC91E0
|
||||||
|
assert mode0["arm"] == 0x147B1AE60
|
||||||
|
assert mode0["arm_writer"] == 0x147B1AF33
|
||||||
|
assert script.count("hbreak *") == 8
|
||||||
|
assert "condition 7 $tracked_ctx != 0" in script
|
||||||
|
assert "disable 2" in script and "enable 2" in script
|
||||||
|
assert "disable 3" in script and "enable 3" in script
|
||||||
|
assert "disable 5" in script and "enable 5" in script
|
||||||
|
assert "disable 8" in script and "enable 8" in script
|
||||||
|
assert "set *(" not in script
|
||||||
|
print("pma_producer_trace selftest: PASS")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pid", nargs="?", type=int)
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--variant", choices=tuple(VARIANTS), default="mode0")
|
||||||
|
parser.add_argument("--print-script", action="store_true")
|
||||||
|
parser.add_argument("--selftest", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.selftest:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = args.pid or transition.find_pid()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||||
|
advance.validate_file(
|
||||||
|
fifa_path,
|
||||||
|
advance.PINNED_FIFA_SHA256,
|
||||||
|
advance.FIFA_MODULE,
|
||||||
|
)
|
||||||
|
cards_base, cards_path = transition.cards_mapping(pid)
|
||||||
|
transition.validate_cards(cards_path)
|
||||||
|
output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log"
|
||||||
|
script = build_script(pid, fifa_base, output, args.variant)
|
||||||
|
environment = effective_environment(pid)
|
||||||
|
print(
|
||||||
|
"PMAPRODUCER PREPARED "
|
||||||
|
f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||||
|
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
|
||||||
|
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if args.print_script:
|
||||||
|
print(script, end="")
|
||||||
|
return 0
|
||||||
|
if not shutil.which("gdb"):
|
||||||
|
print("gdb not found", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
script_path = f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.gdb"
|
||||||
|
Path(script_path).write_text(script, encoding="utf-8")
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||||
|
return 127
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scan for FIFA17 match-team records by the invariant header prefix.
|
||||||
|
|
||||||
|
Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a
|
||||||
|
per-record marker whose value varies between sessions (-1 on 2026-08-24,
|
||||||
|
344065/344064 on 2026-08-25), and filtering on it produced a false negative.
|
||||||
|
|
||||||
|
scan_mt.py [pid]
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PAT = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||||
|
|
||||||
|
|
||||||
|
def find_pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid()
|
||||||
|
if not pid:
|
||||||
|
print(" no FIFA17.exe")
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
mem = open(f"/proc/{pid}/mem", "rb", 0)
|
||||||
|
found = []
|
||||||
|
for line in open(f"/proc/{pid}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||||
|
if not m or m.group(3)[0] != "r":
|
||||||
|
continue
|
||||||
|
lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4)
|
||||||
|
if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
except (OSError, ValueError, OverflowError):
|
||||||
|
continue
|
||||||
|
i = buf.find(PAT)
|
||||||
|
while i >= 0:
|
||||||
|
rec = buf[i:i + 0x80]
|
||||||
|
if len(rec) >= 0x80:
|
||||||
|
tid = struct.unpack_from("<i", rec, 0x14)[0]
|
||||||
|
m18 = struct.unpack_from("<i", rec, 0x18)[0]
|
||||||
|
m1c = struct.unpack_from("<i", rec, 0x1c)[0]
|
||||||
|
xi = list(struct.unpack_from("<11i", rec, 0x20))
|
||||||
|
subs = list(struct.unpack_from("<12i", rec, 0x4c))
|
||||||
|
found.append((lo + i, tid, m18, m1c, xi, subs))
|
||||||
|
i = buf.find(PAT, i + 4)
|
||||||
|
|
||||||
|
print(f" pid={pid} {len(found)} match-team record(s)")
|
||||||
|
for addr, tid, m18, m1c, xi, subs in found:
|
||||||
|
print(f"\n @0x{addr:x}")
|
||||||
|
print(f" +0x14 teamId = {tid}")
|
||||||
|
print(f" +0x18 marker = {m18} +0x1c marker = {m1c}")
|
||||||
|
print(f" XI = {xi}")
|
||||||
|
print(f" subs = {subs}")
|
||||||
|
print(f"\n distinct teamIds: {sorted({t for _a, t, *_r in found})}")
|
||||||
+1101
File diff suppressed because it is too large
Load Diff
+512
@@ -0,0 +1,512 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Supervise one hardware-only FIFA17 match-team writer capture.
|
||||||
|
|
||||||
|
This is the robust fresh-client entry point. It waits for the largest-RSS
|
||||||
|
FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation
|
||||||
|
can construct match teams, and loads a hardware-only GDB Python payload.
|
||||||
|
|
||||||
|
The concurrent read-only structural locator proves when the fixture and final
|
||||||
|
match-team records exist. A zero-hit result is trusted only if gdb is still
|
||||||
|
alive, TracerPid is the gdb process, the payload reported `trace_armed`, no
|
||||||
|
records pre-existed the trace, and two final records then appeared.
|
||||||
|
|
||||||
|
The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1]
|
||||||
|
watchpoint from live RDX. Other payloads trace the final engine writer or its
|
||||||
|
caller; all expose the same `start_trace(log, cards_base)` entry point.
|
||||||
|
|
||||||
|
No INT3/software breakpoints. No client memory writes. /proc/<pid>/mem is opened
|
||||||
|
'rb'. The operator alone drives the game.
|
||||||
|
|
||||||
|
trace_match_team_writer.py --status /tmp/mt-status.json \
|
||||||
|
--trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from offline_match_locator import find_pids, scan_process
|
||||||
|
|
||||||
|
CARDS_IMAGE_BASE = 0x180000000
|
||||||
|
DEFAULT_TIMEOUT = 45 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def cards_base(pid: int) -> int | None:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/maps") as maps:
|
||||||
|
for line in maps:
|
||||||
|
if "CardsDLL_Win64_retail.dll" in line:
|
||||||
|
return int(line.split("-", 1)[0], 16)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def tracer_pid(pid: int) -> int | None:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/status") as status:
|
||||||
|
for line in status:
|
||||||
|
if line.startswith("TracerPid:"):
|
||||||
|
return int(line.split()[1])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def target_state(pid: int) -> str | None:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/status") as status:
|
||||||
|
for line in status:
|
||||||
|
if line.startswith("State:"):
|
||||||
|
return line.split()[1]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_events(path: Path) -> list[dict]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
events = []
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||||
|
for line in handle:
|
||||||
|
try:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def event_counts(events: list[dict]) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for event in events:
|
||||||
|
kind = event.get("event", "unknown")
|
||||||
|
counts[kind] = counts.get(kind, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
class Status:
|
||||||
|
def __init__(self, path: Path, monitor_log: Path):
|
||||||
|
self.path = path
|
||||||
|
self.monitor_log = monitor_log
|
||||||
|
self.data: dict = {"started_unix": time.time(), "state": "starting"}
|
||||||
|
self.write()
|
||||||
|
|
||||||
|
def write(self, **updates):
|
||||||
|
self.data.update(updates)
|
||||||
|
self.data["updated_unix"] = time.time()
|
||||||
|
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||||
|
os.replace(temporary, self.path)
|
||||||
|
|
||||||
|
def log(self, message: str, **payload):
|
||||||
|
record = {"time_unix": time.time(), "message": message, **payload}
|
||||||
|
with self.monitor_log.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
print(message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str:
|
||||||
|
# Wine uses these signals for thread suspension/runtime plumbing. They must
|
||||||
|
# pass through, or batch gdb stops and silently detaches.
|
||||||
|
signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [
|
||||||
|
f"SIG{number}" for number in range(32, 40)
|
||||||
|
]
|
||||||
|
lines = [
|
||||||
|
"set confirm off",
|
||||||
|
"set pagination off",
|
||||||
|
"set height 0",
|
||||||
|
"set width 0",
|
||||||
|
f"attach {pid}",
|
||||||
|
]
|
||||||
|
lines.extend(f"handle {name} nostop noprint pass" for name in signals)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"source {payload}",
|
||||||
|
f'python start_trace({json.dumps(str(trace))}, {cards})',
|
||||||
|
"continue",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def serialise_locations(locations: dict) -> dict:
|
||||||
|
return {key: [asdict(value) for value in values] for key, values in locations.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_gdb(process: subprocess.Popen, status: Status, pid: int):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=12)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
deadline = time.time() + 8
|
||||||
|
while time.time() < deadline and tracer_pid(pid):
|
||||||
|
time.sleep(0.25)
|
||||||
|
status.log(
|
||||||
|
"gdb detached",
|
||||||
|
gdb_returncode=process.returncode,
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
target_state=target_state(pid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--status", type=Path, required=True)
|
||||||
|
parser.add_argument("--trace", type=Path, required=True)
|
||||||
|
parser.add_argument("--gdb-log", type=Path, required=True)
|
||||||
|
parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl"))
|
||||||
|
parser.add_argument("--fixture-index", type=int, default=0)
|
||||||
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||||
|
parser.add_argument("--post-record-wait", type=int, default=12)
|
||||||
|
parser.add_argument(
|
||||||
|
"--arm-check-seconds",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="attach, prove hardware breakpoints arm, then detach without claiming a capture",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wait-for-record-clear",
|
||||||
|
action="store_true",
|
||||||
|
help="keep tracing through abandon; accept creation only after old records disappear",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exclude-pid",
|
||||||
|
action="append",
|
||||||
|
type=int,
|
||||||
|
default=[],
|
||||||
|
help="ignore an existing FIFA process and attach only after process replacement",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--payload",
|
||||||
|
default="gdb_match_team_writer_trace.py",
|
||||||
|
help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
for path in (args.status, args.trace, args.gdb_log, args.monitor_log):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
for path in (args.trace, args.gdb_log, args.monitor_log):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
status = Status(args.status, args.monitor_log)
|
||||||
|
payload = Path(__file__).with_name(args.payload).resolve()
|
||||||
|
if not payload.exists():
|
||||||
|
status.write(state="failed", error=f"missing gdb payload: {payload}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
deadline = time.time() + args.timeout
|
||||||
|
status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid)
|
||||||
|
status.log(
|
||||||
|
"waiting for FIFA17.exe with CardsDLL",
|
||||||
|
excluded_pids=args.exclude_pid,
|
||||||
|
)
|
||||||
|
pid = None
|
||||||
|
cards = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
# UMU/Proton creates a short-lived small FIFA17.exe before the real
|
||||||
|
# client. Never bind to the first comm match. Require CardsDLL and prefer
|
||||||
|
# the largest-RSS process (find_pids is ordered that way).
|
||||||
|
for candidate in find_pids():
|
||||||
|
if candidate in args.exclude_pid:
|
||||||
|
continue
|
||||||
|
candidate_cards = cards_base(candidate)
|
||||||
|
if candidate_cards:
|
||||||
|
pid, cards = candidate, candidate_cards
|
||||||
|
break
|
||||||
|
if pid:
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
if not pid or not cards:
|
||||||
|
status.write(state="timed_out", phase="ready_process")
|
||||||
|
return 3
|
||||||
|
|
||||||
|
status.write(state="ready_process_found", pid=pid, cards_base=cards)
|
||||||
|
status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards)
|
||||||
|
|
||||||
|
command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb"
|
||||||
|
command_path.write_text(gdb_commands(pid, cards, payload, args.trace))
|
||||||
|
gdb_handle = args.gdb_log.open("w", encoding="utf-8")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["gdb", "-q", "-nx", "-x", str(command_path)],
|
||||||
|
stdout=gdb_handle,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
status.write(
|
||||||
|
state="attaching",
|
||||||
|
pid=pid,
|
||||||
|
cards_base=cards,
|
||||||
|
cards_image_base=CARDS_IMAGE_BASE,
|
||||||
|
gdb_pid=process.pid,
|
||||||
|
gdb_command_file=str(command_path),
|
||||||
|
payload=args.payload,
|
||||||
|
hardware_only=True,
|
||||||
|
client_memory_writes=False,
|
||||||
|
)
|
||||||
|
status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards)
|
||||||
|
|
||||||
|
armed = False
|
||||||
|
arm_deadline = min(deadline, time.time() + 60)
|
||||||
|
while time.time() < arm_deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
break
|
||||||
|
events = read_events(args.trace)
|
||||||
|
if any(event.get("event") == "trace_armed" for event in events):
|
||||||
|
armed = True
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
if not armed:
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(
|
||||||
|
state="failed",
|
||||||
|
phase="arm",
|
||||||
|
gdb_returncode=process.poll(),
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
trace_events=event_counts(read_events(args.trace)),
|
||||||
|
)
|
||||||
|
if process.poll() is None:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
return 4
|
||||||
|
|
||||||
|
attached = tracer_pid(pid) == process.pid
|
||||||
|
status.write(
|
||||||
|
state="armed",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
target_state=target_state(pid),
|
||||||
|
trace_events=event_counts(read_events(args.trace)),
|
||||||
|
execution_breakpoints_armed=True,
|
||||||
|
team1_watchpoint_armed=False,
|
||||||
|
)
|
||||||
|
status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid))
|
||||||
|
if not attached:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(state="failed", phase="attach_verification")
|
||||||
|
return 4
|
||||||
|
if args.arm_check_seconds > 0:
|
||||||
|
time.sleep(args.arm_check_seconds)
|
||||||
|
events = read_events(args.trace)
|
||||||
|
counts = event_counts(events)
|
||||||
|
still_attached = tracer_pid(pid) == process.pid and process.poll() is None
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
passed = (
|
||||||
|
still_attached
|
||||||
|
and counts.get("trace_armed", 0) == 1
|
||||||
|
and counts.get("trace_error", 0) == 0
|
||||||
|
and tracer_pid(pid) == 0
|
||||||
|
and target_state(pid) != "T"
|
||||||
|
)
|
||||||
|
status.write(
|
||||||
|
state="arm_check_passed" if passed else "arm_check_failed",
|
||||||
|
trace_events=counts,
|
||||||
|
attached_before_detach=still_attached,
|
||||||
|
tracer_pid_after_detach=tracer_pid(pid),
|
||||||
|
target_state_after_detach=target_state(pid),
|
||||||
|
)
|
||||||
|
status.log("arm check complete", passed=passed, trace_events=counts)
|
||||||
|
return 0 if passed else 5
|
||||||
|
|
||||||
|
# A final record that already exists before arming cannot prove execution
|
||||||
|
# crossed creation under the debugger. Fail closed instead of converting an
|
||||||
|
# already-built match into a trusted zero-hit result.
|
||||||
|
initial_heap = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
include_fixture=False,
|
||||||
|
writable_anon_only=True,
|
||||||
|
)
|
||||||
|
records_preexisting = len(initial_heap["match_teams"]) >= 2
|
||||||
|
records_cleared = not records_preexisting
|
||||||
|
if records_preexisting and args.wait_for_record_clear:
|
||||||
|
status.write(
|
||||||
|
state="waiting_for_record_clear",
|
||||||
|
locations=serialise_locations(initial_heap),
|
||||||
|
target_crossed_match_team_creation=False,
|
||||||
|
)
|
||||||
|
status.log(
|
||||||
|
"trace armed; waiting for old match-team records to disappear",
|
||||||
|
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||||
|
)
|
||||||
|
while time.time() < deadline:
|
||||||
|
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(state="failed", phase="record_clear")
|
||||||
|
return 5
|
||||||
|
heap = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
include_fixture=False,
|
||||||
|
writable_anon_only=True,
|
||||||
|
)
|
||||||
|
if not heap["match_teams"]:
|
||||||
|
records_cleared = True
|
||||||
|
status.write(
|
||||||
|
state="records_cleared",
|
||||||
|
cleared_unix=time.time(),
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
gdb_alive=process.poll() is None,
|
||||||
|
target_state=target_state(pid),
|
||||||
|
)
|
||||||
|
status.log(
|
||||||
|
"old match-team records disappeared; next records are a fresh creation",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
if not records_cleared:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(state="timed_out", phase="record_clear")
|
||||||
|
return 3
|
||||||
|
elif records_preexisting:
|
||||||
|
counts = event_counts(read_events(args.trace))
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
status.write(
|
||||||
|
state="armed_too_late",
|
||||||
|
phase="preexisting_records",
|
||||||
|
trace_events=counts,
|
||||||
|
locations=serialise_locations(initial_heap),
|
||||||
|
target_crossed_match_team_creation=False,
|
||||||
|
tracer_pid_after_detach=tracer_pid(pid),
|
||||||
|
target_state_after_detach=target_state(pid),
|
||||||
|
)
|
||||||
|
status.log(
|
||||||
|
"match-team records pre-existed trace; no writer claim",
|
||||||
|
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||||
|
)
|
||||||
|
return 6
|
||||||
|
|
||||||
|
|
||||||
|
fixture = None
|
||||||
|
latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||||
|
last_fixture_scan = 0.0
|
||||||
|
records_seen_at = None
|
||||||
|
record_control = None
|
||||||
|
try:
|
||||||
|
while time.time() < deadline:
|
||||||
|
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||||
|
status.write(
|
||||||
|
state="failed",
|
||||||
|
phase="monitor",
|
||||||
|
gdb_returncode=process.poll(),
|
||||||
|
target_exists=Path(f"/proc/{pid}").exists(),
|
||||||
|
)
|
||||||
|
return 5
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if fixture is None and now - last_fixture_scan >= 8:
|
||||||
|
full = scan_process(pid, args.fixture_index, include_fixture=True)
|
||||||
|
last_fixture_scan = now
|
||||||
|
if full["fixtures"]:
|
||||||
|
fixture = full["fixtures"][0]
|
||||||
|
latest_locations["fixtures"] = full["fixtures"]
|
||||||
|
status.log(
|
||||||
|
"fixture located",
|
||||||
|
address=fixture.address,
|
||||||
|
selected_address=fixture.selected_address,
|
||||||
|
selected_index=fixture.selected_index,
|
||||||
|
selected_team_id=fixture.selected_team_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
heap = scan_process(
|
||||||
|
pid,
|
||||||
|
args.fixture_index,
|
||||||
|
include_fixture=False,
|
||||||
|
writable_anon_only=True,
|
||||||
|
)
|
||||||
|
latest_locations["match_teams"] = heap["match_teams"]
|
||||||
|
latest_locations["match_configs"] = heap["match_configs"]
|
||||||
|
events = read_events(args.trace)
|
||||||
|
counts = event_counts(events)
|
||||||
|
is_attached = tracer_pid(pid) == process.pid
|
||||||
|
watch_armed = counts.get("team1_watchpoint_armed", 0) > 0
|
||||||
|
status.write(
|
||||||
|
state="capturing" if len(heap["match_teams"]) < 2 else "records_observed",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
gdb_alive=process.poll() is None,
|
||||||
|
target_state=target_state(pid),
|
||||||
|
trace_events=counts,
|
||||||
|
team1_watchpoint_armed=watch_armed,
|
||||||
|
locations=serialise_locations(latest_locations),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(heap["match_teams"]) >= 2:
|
||||||
|
if records_seen_at is None:
|
||||||
|
if not is_attached or process.poll() is not None:
|
||||||
|
status.write(
|
||||||
|
state="failed",
|
||||||
|
phase="record_creation_control",
|
||||||
|
tracer_pid=tracer_pid(pid),
|
||||||
|
gdb_alive=process.poll() is None,
|
||||||
|
trace_events=counts,
|
||||||
|
)
|
||||||
|
return 5
|
||||||
|
records_seen_at = now
|
||||||
|
record_control = {
|
||||||
|
"gdb_alive": process.poll() is None,
|
||||||
|
"tracer_pid": tracer_pid(pid),
|
||||||
|
"attached": is_attached,
|
||||||
|
"execution_breakpoints_armed": counts.get("trace_armed", 0) == 1,
|
||||||
|
"team1_watchpoint_armed": watch_armed,
|
||||||
|
}
|
||||||
|
status.log(
|
||||||
|
"two match-team records located",
|
||||||
|
team_ids=[team.team_id for team in heap["match_teams"]],
|
||||||
|
trace_events=counts,
|
||||||
|
**record_control,
|
||||||
|
)
|
||||||
|
if now - records_seen_at >= args.post_record_wait:
|
||||||
|
break
|
||||||
|
time.sleep(3)
|
||||||
|
finally:
|
||||||
|
terminate_gdb(process, status, pid)
|
||||||
|
gdb_handle.close()
|
||||||
|
|
||||||
|
events = read_events(args.trace)
|
||||||
|
counts = event_counts(events)
|
||||||
|
final = {
|
||||||
|
"state": "captured",
|
||||||
|
"pid": pid,
|
||||||
|
"cards_base": cards,
|
||||||
|
"fixture": asdict(fixture) if fixture else None,
|
||||||
|
"locations": serialise_locations(latest_locations),
|
||||||
|
"trace_events": counts,
|
||||||
|
"record_creation_control": record_control,
|
||||||
|
"gdb_alive_at_record_creation": bool(
|
||||||
|
record_control and record_control["gdb_alive"] and record_control["attached"]
|
||||||
|
),
|
||||||
|
"target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2,
|
||||||
|
"candidate_entry_hit": counts.get("candidate_entry", 0) > 0,
|
||||||
|
"team1_write_hit": counts.get("team1_write_post", 0) > 0,
|
||||||
|
"opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0,
|
||||||
|
"tracer_pid_after_detach": tracer_pid(pid),
|
||||||
|
"target_state_after_detach": target_state(pid),
|
||||||
|
"records_preexisting": records_preexisting,
|
||||||
|
"records_cleared_before_capture": records_cleared,
|
||||||
|
}
|
||||||
|
status.write(**final)
|
||||||
|
status.log("capture complete", **final)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dump a CardsDLL vtable as image VAs, and find sibling vtables that hold a
|
||||||
|
different function in the same slot (a type/mode dispatch).
|
||||||
|
|
||||||
|
vtab.py <slot_image_va_hex> [before] [after]
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise SystemExit("no FIFA17.exe")
|
||||||
|
|
||||||
|
|
||||||
|
P = pid()
|
||||||
|
BASE = [int(l.split("-")[0], 16) for l in open(f"/proc/{P}/maps") if "CardsDLL" in l][0]
|
||||||
|
|
||||||
|
|
||||||
|
def img2live(va):
|
||||||
|
return BASE + (va - CARDS_IMG)
|
||||||
|
|
||||||
|
|
||||||
|
def live2img(la):
|
||||||
|
return CARDS_IMG + (la - BASE)
|
||||||
|
|
||||||
|
|
||||||
|
slot = int(sys.argv[1], 16)
|
||||||
|
before = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||||
|
after = int(sys.argv[3]) if len(sys.argv) > 3 else 10
|
||||||
|
|
||||||
|
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||||
|
start = slot - before * 8
|
||||||
|
mem.seek(img2live(start))
|
||||||
|
buf = mem.read((before + after) * 8)
|
||||||
|
print(f" vtable neighbourhood of image 0x{slot:x}")
|
||||||
|
target = None
|
||||||
|
for k in range(0, len(buf) - 7, 8):
|
||||||
|
a = start + k
|
||||||
|
p = struct.unpack_from("<Q", buf, k)[0]
|
||||||
|
ivа = live2img(p) if BASE <= p < BASE + 0x400000 else None
|
||||||
|
mark = " <== the team-pair assigner" if a == slot else ""
|
||||||
|
if a == slot:
|
||||||
|
target = ivа
|
||||||
|
print(f" 0x{a:x} [{a-slot:+#5x}] -> "
|
||||||
|
+ (f"image 0x{ivа:x}" if ivа else f"raw 0x{p:x}") + mark)
|
||||||
|
|
||||||
|
# Find every other .rdata slot pointing at a DIFFERENT function but whose
|
||||||
|
# neighbours overlap this vtable -> sibling implementations of the same slot.
|
||||||
|
print("\n === sibling vtables: same neighbour, different slot function ===")
|
||||||
|
mem.seek(img2live(0x1801e5000))
|
||||||
|
rdata = mem.read(0x28a000 - 0x1e5000)
|
||||||
|
# take the two neighbours around the slot as a signature
|
||||||
|
sig_prev = struct.unpack_from("<Q", buf, (before - 1) * 8)[0]
|
||||||
|
sig_next = struct.unpack_from("<Q", buf, (before + 1) * 8)[0]
|
||||||
|
found = 0
|
||||||
|
for name, sig in (("preceding", sig_prev), ("following", sig_next)):
|
||||||
|
pat = struct.pack("<Q", sig)
|
||||||
|
i = rdata.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
if i % 8 == 0:
|
||||||
|
here = 0x1801e5000 + i
|
||||||
|
# the slot in THIS vtable at the same relative position
|
||||||
|
off = i + (8 if name == "preceding" else -8)
|
||||||
|
if 0 <= off <= len(rdata) - 8:
|
||||||
|
fn = struct.unpack_from("<Q", rdata, off)[0]
|
||||||
|
if BASE <= fn < BASE + 0x400000:
|
||||||
|
fimg = live2img(fn)
|
||||||
|
if fimg != target:
|
||||||
|
print(f" vtable @image 0x{here:x} ({name} matches) "
|
||||||
|
f"slot -> image 0x{fimg:x} DIFFERENT")
|
||||||
|
found += 1
|
||||||
|
i = rdata.find(pat, i + 1)
|
||||||
|
print(f" {found} sibling implementation(s)")
|
||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find references to an image VA inside a live module's .text/.rdata/.data.
|
||||||
|
|
||||||
|
xref.py <target_image_va_hex> [--exe]
|
||||||
|
|
||||||
|
Reports:
|
||||||
|
call rel32 (e8) / jmp rel32 (e9) -- direct callers
|
||||||
|
lea rip-rel (48 8d 0x) -- address-taken
|
||||||
|
absolute 8-byte pointer -- vtable / table slot
|
||||||
|
|
||||||
|
Read-only. Section ranges are recomputed from /proc/<pid>/maps every run.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CARDS_IMG = 0x180000000
|
||||||
|
EXE_IMG = 0x140000000
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
for d in glob.glob("/proc/[0-9]*"):
|
||||||
|
try:
|
||||||
|
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||||
|
return int(os.path.basename(d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise SystemExit("FIFA17.exe not running")
|
||||||
|
|
||||||
|
|
||||||
|
P = pid()
|
||||||
|
|
||||||
|
|
||||||
|
def module_base(needle):
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
if needle.lower() in l.lower():
|
||||||
|
return int(l.split("-")[0], 16)
|
||||||
|
raise SystemExit(f"{needle} not mapped")
|
||||||
|
|
||||||
|
|
||||||
|
def spans(base, limit=0x400000):
|
||||||
|
"""Contiguous mappings belonging to this module, as (live_lo, live_hi, perms)."""
|
||||||
|
out = []
|
||||||
|
for l in open(f"/proc/{P}/maps"):
|
||||||
|
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||||
|
if lo == base:
|
||||||
|
out.append((lo, hi, perms))
|
||||||
|
continue
|
||||||
|
if out and lo == out[-1][1] and not path.strip():
|
||||||
|
out.append((lo, hi, perms))
|
||||||
|
elif out and lo > out[-1][1]:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = [x for x in sys.argv[1:] if x != "--exe"]
|
||||||
|
exe = "--exe" in sys.argv
|
||||||
|
target = int(a[0], 16)
|
||||||
|
img = EXE_IMG if exe else CARDS_IMG
|
||||||
|
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||||
|
tgt_live = base + (target - img)
|
||||||
|
|
||||||
|
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||||
|
print(f" pid={P} module_base=0x{base:x} target image 0x{target:x} live 0x{tgt_live:x}")
|
||||||
|
hits = 0
|
||||||
|
for lo, hi, perms in spans(base):
|
||||||
|
try:
|
||||||
|
mem.seek(lo)
|
||||||
|
buf = mem.read(hi - lo)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
img_lo = img + (lo - base)
|
||||||
|
# rel32 call/jmp
|
||||||
|
for op, name in ((0xE8, "call"), (0xE9, "jmp ")):
|
||||||
|
i = buf.find(bytes([op]))
|
||||||
|
while i >= 0:
|
||||||
|
if i + 5 <= len(buf):
|
||||||
|
rel = struct.unpack_from("<i", buf, i + 1)[0]
|
||||||
|
if img_lo + i + 5 + rel == target:
|
||||||
|
print(f" {name} rel32 from image 0x{img_lo+i:x} [{perms}]")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(bytes([op]), i + 1)
|
||||||
|
# lea reg,[rip+rel32] (48 8d /r with mod=00 rm=101)
|
||||||
|
i = buf.find(b"\x48\x8d")
|
||||||
|
while i >= 0:
|
||||||
|
if i + 7 <= len(buf):
|
||||||
|
modrm = buf[i + 2]
|
||||||
|
if (modrm & 0xC7) == 0x05:
|
||||||
|
rel = struct.unpack_from("<i", buf, i + 3)[0]
|
||||||
|
if img_lo + i + 7 + rel == target:
|
||||||
|
print(f" lea rip-rel from image 0x{img_lo+i:x} [{perms}]")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(b"\x48\x8d", i + 1)
|
||||||
|
# absolute pointer (live address stored in a table)
|
||||||
|
pat = struct.pack("<Q", tgt_live)
|
||||||
|
i = buf.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
if i % 8 == 0:
|
||||||
|
print(f" abs ptr slot at image 0x{img_lo+i:x} [{perms}]")
|
||||||
|
hits += 1
|
||||||
|
i = buf.find(pat, i + 1)
|
||||||
|
print(f" {hits} reference(s)")
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -41,6 +41,20 @@ 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,
|
||||||
@@ -206,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>,
|
||||||
@@ -287,6 +304,7 @@ 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),
|
category: rc.category.unwrap_or(0),
|
||||||
year: rc.year.unwrap_or(0),
|
year: rc.year.unwrap_or(0),
|
||||||
@@ -380,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.
|
||||||
|
|||||||
@@ -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,11 +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::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;
|
||||||
|
|
||||||
@@ -185,23 +188,37 @@ 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
|
||||||
@@ -211,11 +228,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
|
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
|
||||||
{
|
{
|
||||||
Some((mgr, id)) => json!([{
|
Some((mgr, id)) => {
|
||||||
"id": id.item_id,
|
let mut item = shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT));
|
||||||
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
|
if let Some(obj) = item.as_object_mut() {
|
||||||
"dream": false,
|
obj.insert("dream".to_string(), json!(false));
|
||||||
}]),
|
}
|
||||||
|
json!([item])
|
||||||
|
}
|
||||||
None => json!([]),
|
None => json!([]),
|
||||||
};
|
};
|
||||||
let squad = json!({
|
let squad = json!({
|
||||||
@@ -235,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
|
||||||
}
|
}
|
||||||
@@ -525,14 +605,13 @@ mod tests {
|
|||||||
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,
|
||||||
@@ -544,10 +623,20 @@ mod tests {
|
|||||||
"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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -408,10 +410,13 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
|||||||
}
|
}
|
||||||
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
// 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"
|
||||||
@@ -420,8 +425,11 @@ 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!(
|
assert_eq!(
|
||||||
@@ -503,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"]);
|
||||||
|
|
||||||
@@ -530,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: 1df03d4287...20e281e0cf
+1
-1
Submodule openfut-launcher updated: d7641175be...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!(
|
||||||
|
|||||||
+386
-43
@@ -76,12 +76,16 @@ use openfut_adapter_fifa17::fut::owned_query::{
|
|||||||
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
|
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
|
||||||
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
|
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
|
||||||
use openfut_adapter_fifa17::fut::season_wire;
|
use openfut_adapter_fifa17::fut::season_wire;
|
||||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver};
|
use openfut_adapter_fifa17::fut::squad::{
|
||||||
|
classify_squad_put, save_ack, Fifa17SquadPut, Fifa17SquadRolePatch, SquadMutation,
|
||||||
|
SquadWireResolver,
|
||||||
|
};
|
||||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||||
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
build_squad_write, Fifa17SquadExtensionV1, KicktakerRef, SquadBuildError, WireItemRef,
|
||||||
|
EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
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 openfut_adapter_fifa17::fut::store_catalog::{
|
use openfut_adapter_fifa17::fut::store_catalog::{
|
||||||
@@ -206,8 +210,8 @@ pub enum Route {
|
|||||||
|
|
||||||
/// Classify a request ONCE, before execution. Rust owns complete route families;
|
/// Classify a request ONCE, before execution. Rust owns complete route families;
|
||||||
/// there is no "try Rust then Python", so a mutation can never be double-applied.
|
/// there is no "try Rust then Python", so a mutation can never be double-applied.
|
||||||
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
|
/// Numeric `…/squad/<n>` resolves to the one Core-backed squad. That is SAFE
|
||||||
/// every numeric id returns the one Core-backed active squad.
|
/// rather than authentic — see [`is_numeric_squad_tail`].
|
||||||
pub fn classify(method: &str, path: &str) -> Route {
|
pub fn classify(method: &str, path: &str) -> Route {
|
||||||
let get = method.eq_ignore_ascii_case("GET");
|
let get = method.eq_ignore_ascii_case("GET");
|
||||||
let put = method.eq_ignore_ascii_case("PUT");
|
let put = method.eq_ignore_ascii_case("PUT");
|
||||||
@@ -237,7 +241,15 @@ pub fn classify(method: &str, path: &str) -> Route {
|
|||||||
Some("squad/list") if get => Route::SquadList,
|
Some("squad/list") if get => Route::SquadList,
|
||||||
Some("squad/active") if get => Route::SquadActive,
|
Some("squad/active") if get => Route::SquadActive,
|
||||||
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
|
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
|
||||||
Some("userMassInfo") if get => Route::UserMassInfo,
|
// The retail client sends BOTH casings: a lowercase `usermassinfo`
|
||||||
|
// followed immediately by the canonical `userMassInfo`. Matching the
|
||||||
|
// literal only meant the lowercase one fell through to the Python
|
||||||
|
// upstream — a 502 here, but on a deployment with Python alive it would
|
||||||
|
// be ANSWERED there, silently splitting authority away from Rust for a
|
||||||
|
// route Core owns. Matched case-insensitively for this tail only; the
|
||||||
|
// rest of the table stays exact, since no other route has shown a
|
||||||
|
// casing variant.
|
||||||
|
Some(t) if get && t.eq_ignore_ascii_case("usermassinfo") => Route::UserMassInfo,
|
||||||
Some("user/club") if put || post => Route::ClubRename,
|
Some("user/club") if put || post => Route::ClubRename,
|
||||||
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
|
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
|
||||||
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
||||||
@@ -395,9 +407,35 @@ fn is_exact_club_path(path: &str) -> bool {
|
|||||||
ut_tail(path) == Some("club")
|
ut_tail(path) == Some("club")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
|
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET.
|
||||||
/// stores one current squad, matching the oracle: every numeric GET returns that
|
///
|
||||||
/// same squad regardless of the requested id.
|
/// Every numeric id resolves to the one Core-backed squad. Be precise about why
|
||||||
|
/// that is acceptable: it is SAFE, not authentic.
|
||||||
|
///
|
||||||
|
/// FIFA 17 genuinely has multi-squad semantics. The client ships
|
||||||
|
/// `SelectSquadById`, `RetrieveSquad` as an action DISTINCT from
|
||||||
|
/// `LoadActiveSquad`, plus `CreateSquadWithName`, `RenameSquad`, `DeleteSquad`,
|
||||||
|
/// `CopySquad`, indexed `SQUAD_ID-%d` list entries and a
|
||||||
|
/// `FUT_MAX_NUM_SQUAD_REACHED` string. The number is therefore a real squad
|
||||||
|
/// identifier, not a placeholder.
|
||||||
|
///
|
||||||
|
/// It is unreachable here because we advertise exactly ONE squad:
|
||||||
|
/// [`ACTIVE_SQUAD_WIRE_ID`] is a constant `0`, `/squad/list` returns a
|
||||||
|
/// single-element array carrying that id, and no create/rename/delete/copy
|
||||||
|
/// route exists. The client can only ever echo back the id we gave it — every
|
||||||
|
/// numeric path observed in retained captures is `squad/0`, all of them PUTs
|
||||||
|
/// whose body `id` also reads 0, and no numeric GET has ever been recorded.
|
||||||
|
///
|
||||||
|
/// So collapsing the id costs nothing TODAY and is pinned by
|
||||||
|
/// `numeric_squad_routing_is_safe_only_while_one_squad_is_advertised`. The
|
||||||
|
/// moment a second squad becomes addressable, that test must fail and this
|
||||||
|
/// routing must gain a real lookup. Do NOT widen squad support without
|
||||||
|
/// revisiting it.
|
||||||
|
///
|
||||||
|
/// Unknown-id behaviour is deliberately NOT invented: no FIFA 17 evidence shows
|
||||||
|
/// what the client expects for an id it was never given. (The FIFA 14 oracle
|
||||||
|
/// Impulsum14 returns an empty squad carrying that id, never the active one —
|
||||||
|
/// hypothesis only, not FIFA 17 truth.)
|
||||||
fn is_numeric_squad_tail(tail: &str) -> bool {
|
fn is_numeric_squad_tail(tail: &str) -> bool {
|
||||||
match tail.strip_prefix("squad/") {
|
match tail.strip_prefix("squad/") {
|
||||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
||||||
@@ -776,6 +814,21 @@ pub struct CoreReplaceRequest {
|
|||||||
pub ext_payload: String,
|
pub ext_payload: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A role-only squad patch: captain plus the opaque extension, nothing else.
|
||||||
|
///
|
||||||
|
/// Deliberately has no `slots`, `name`, `formation` or manager field — the
|
||||||
|
/// absence is structural, so this request cannot express a squad replacement
|
||||||
|
/// even by mistake.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CoreRolePatchRequest {
|
||||||
|
/// Owned instance to flag as captain. `None` leaves the captain unchanged;
|
||||||
|
/// it is never a request to clear it.
|
||||||
|
pub captain_owned_card_id: Option<String>,
|
||||||
|
pub ext_namespace: String,
|
||||||
|
pub ext_schema_version: i64,
|
||||||
|
pub ext_payload: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Client-reported shadow evaluation carried through to Core (never Core's
|
/// Client-reported shadow evaluation carried through to Core (never Core's
|
||||||
/// authoritative evaluation).
|
/// authoritative evaluation).
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -844,6 +897,18 @@ pub trait CoreAccess: Send + Sync {
|
|||||||
/// Replace the active squad's canonical slots + opaque extension atomically.
|
/// Replace the active squad's canonical slots + opaque extension atomically.
|
||||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
|
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
|
||||||
|
|
||||||
|
/// Patch ONLY the active squad's role assignments (captain) + opaque
|
||||||
|
/// extension. Never touches player assignments, the manager, or actives.
|
||||||
|
///
|
||||||
|
/// Separate from [`Self::replace_squad`] because they are different
|
||||||
|
/// operations: a role-only client update carries no slot array, and sending
|
||||||
|
/// it as a replacement presents zero slots to Core's empty-replacement
|
||||||
|
/// guard. Default is `Status(501)` so a transport that has not implemented
|
||||||
|
/// it fails loudly instead of silently degrading into a replacement.
|
||||||
|
fn patch_squad_roles(&self, _req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||||
|
Err(CoreError::Status(501))
|
||||||
|
}
|
||||||
|
|
||||||
/// The owned instance id assigned as the active squad's **manager**, or
|
/// The owned instance id assigned as the active squad's **manager**, or
|
||||||
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
|
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
|
||||||
/// endpoint simply projects no manager (non-fatal, like an absent
|
/// endpoint simply projects no manager (non-fatal, like an absent
|
||||||
@@ -852,11 +917,19 @@ pub trait CoreAccess: Send + Sync {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assign (`Some`) or clear (`None`) the active squad's **manager**
|
/// ASSIGN the active squad's **manager** (`PUT /club/manager`). Default:
|
||||||
/// (`PUT /club/manager`). Default: unimplemented — the production
|
/// unimplemented — the production `HttpCoreClient` overrides it; a transport
|
||||||
/// `HttpCoreClient` overrides it; a transport that cannot persist the
|
/// that cannot persist the assignment MUST fail loudly rather than silently
|
||||||
/// assignment MUST fail loudly rather than silently drop it.
|
/// drop it.
|
||||||
fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
///
|
||||||
|
/// Deliberately takes `&str`, NOT `Option<&str>`: there is no FIFA 17 wire
|
||||||
|
/// shape that removes a manager. The client always sends a manager ref, so
|
||||||
|
/// "no ownership-backed manager in this save" means the ref was missing or
|
||||||
|
/// unmappable — never that the user cleared the slot. Passing `None` here
|
||||||
|
/// used to be forwarded as an explicit null and DELETED the assignment; that
|
||||||
|
/// is how a client with a destroyed squad model wiped a real manager row.
|
||||||
|
/// The capability is gone at the type level so the host cannot express it.
|
||||||
|
fn set_squad_manager(&self, _owned_card_id: &str) -> Result<(), CoreError> {
|
||||||
Err(CoreError::Parse(
|
Err(CoreError::Parse(
|
||||||
"Core squad manager write is not implemented".into(),
|
"Core squad manager write is not implemented".into(),
|
||||||
))
|
))
|
||||||
@@ -1008,6 +1081,29 @@ impl CoreAccess for HttpCoreClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||||
|
let url = format!("{}/squad/roles", self.base_url);
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.put(&url)
|
||||||
|
.header("X-OpenFUT-Game", &self.game)
|
||||||
|
.json(&json!({
|
||||||
|
"captain_owned_card_id": req.captain_owned_card_id,
|
||||||
|
"extension": {
|
||||||
|
"namespace": req.ext_namespace,
|
||||||
|
"schema_version": req.ext_schema_version,
|
||||||
|
"payload": req.ext_payload,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| CoreError::Http(e.to_string()))?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
return Err(CoreError::Status(status));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||||
let url = format!("{}/club/manager", self.base_url);
|
let url = format!("{}/club/manager", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
@@ -1039,7 +1135,7 @@ impl CoreAccess for HttpCoreClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
fn set_squad_manager(&self, owned_card_id: &str) -> Result<(), CoreError> {
|
||||||
let url = format!("{}/club/manager", self.base_url);
|
let url = format!("{}/club/manager", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -2120,7 +2216,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
|||||||
}
|
}
|
||||||
Some(Fifa17KitIdentity {
|
Some(Fifa17KitIdentity {
|
||||||
item_id: self.wire_for(item)?,
|
item_id: self.wire_for(item)?,
|
||||||
asset_id: ident.asset_id,
|
// The club-item wire `assetId` is family specific and is NOT the
|
||||||
|
// carddbid — see `Fifa17CardIdentity::club_asset_id`.
|
||||||
|
asset_id: ident.club_asset_id,
|
||||||
resource_id: ident.resource_id,
|
resource_id: ident.resource_id,
|
||||||
card_asset_id: ident.card_asset_id,
|
card_asset_id: ident.card_asset_id,
|
||||||
subtype: ident.subtype,
|
subtype: ident.subtype,
|
||||||
@@ -2652,6 +2750,8 @@ pub struct SquadDeps<'a> {
|
|||||||
pub core: &'a dyn CoreAccess,
|
pub core: &'a dyn CoreAccess,
|
||||||
pub resolver: &'a Fifa17IdentityResolver,
|
pub resolver: &'a Fifa17IdentityResolver,
|
||||||
pub entities: &'a Fifa17Entities,
|
pub entities: &'a Fifa17Entities,
|
||||||
|
/// Emit `squad.actives`. Default OFF — see [`crate::config::HostConfig`].
|
||||||
|
pub squad_actives: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Secret-free structured log line for a handled squad request.
|
/// Secret-free structured log line for a handled squad request.
|
||||||
@@ -2663,8 +2763,10 @@ pub struct SquadLog {
|
|||||||
|
|
||||||
/// The active squad projected from Core, with the freshness policy applied.
|
/// The active squad projected from Core, with the freshness policy applied.
|
||||||
enum HostProjection {
|
enum HostProjection {
|
||||||
/// Fresh: the projected FIFA squad object (before any endpoint envelope).
|
/// Fresh: the projected FIFA squad object (before any endpoint envelope),
|
||||||
Squad(Value),
|
/// plus the active club items for its `actives` array. They travel together
|
||||||
|
/// because both are derived from the SAME bounded Core fetch.
|
||||||
|
Squad { squad: Value, actives: Value },
|
||||||
/// Stored extension is stale vs the canonical squad — NEVER applied.
|
/// Stored extension is stale vs the canonical squad — NEVER applied.
|
||||||
Stale,
|
Stale,
|
||||||
/// No extension stored — nothing fabricated.
|
/// No extension stored — nothing fabricated.
|
||||||
@@ -2747,8 +2849,38 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
|||||||
owned: &owned_by_id,
|
owned: &owned_by_id,
|
||||||
manager,
|
manager,
|
||||||
};
|
};
|
||||||
|
// The active club designations Core already owns (`/club/active-items`), shaped
|
||||||
|
// into the `actives` array that makes a club item resident.
|
||||||
|
//
|
||||||
|
// DEFAULT OFF (`HostConfig::squad_actives`). Populating this array does make
|
||||||
|
// the kits resident and the pre-match selector work, but it was also observed
|
||||||
|
// to cost the rest of the squad: the resident item map fell from 24 entries to
|
||||||
|
// just the 2 kits, the 23-slot player vector came back fully null, and the
|
||||||
|
// starting-11 screen was empty. `actives` sorts first in the squad object, so
|
||||||
|
// everything after it is lost. Until that is understood an empty squad is far
|
||||||
|
// worse than a missing kit.
|
||||||
|
let actives = if !deps.squad_actives {
|
||||||
|
json!([])
|
||||||
|
} else {
|
||||||
|
match deps.core.get_active_kits() {
|
||||||
|
Ok(assignments) => squad_actives(
|
||||||
|
&owned_by_id,
|
||||||
|
deps.resolver,
|
||||||
|
ActiveKitAssignments {
|
||||||
|
home: assignments.home_owned_card_id.as_deref(),
|
||||||
|
away: assignments.away_owned_card_id.as_deref(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!(
|
||||||
|
"utas-host WARN active club items unavailable, squad.actives empty: {error}"
|
||||||
|
);
|
||||||
|
json!([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
match project_squad(&input, deps.resolver, deps.entities) {
|
match project_squad(&input, deps.resolver, deps.entities) {
|
||||||
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
|
Ok(SquadProjection::Projected(squad)) => HostProjection::Squad { squad, actives },
|
||||||
Ok(SquadProjection::Stale) => HostProjection::Stale,
|
Ok(SquadProjection::Stale) => HostProjection::Stale,
|
||||||
Ok(SquadProjection::Missing) => HostProjection::Missing,
|
Ok(SquadProjection::Missing) => HostProjection::Missing,
|
||||||
Err(e) => HostProjection::Error(e.to_string()),
|
Err(e) => HostProjection::Error(e.to_string()),
|
||||||
@@ -2766,26 +2898,35 @@ fn error_response(status: u16, code: &str) -> WireResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT …/squad/<n>` — parse the full replacement, reverse-resolve every wire id,
|
/// `PUT …/squad/<n>` — dispatch on which mutation the body actually expresses.
|
||||||
/// AUTHORIZE every resolved item against the active club, then commit the
|
///
|
||||||
/// canonical squad + FIFA extension to Core in one transaction. On any failure
|
/// FIFA 17 sends two operations down this one path and distinguishes them only
|
||||||
/// it returns an error and NEVER falls back to Python (no double mutation).
|
/// by body shape (see [`SquadMutation`]). Classifying FIRST is the whole fix: a
|
||||||
|
/// role-only update carries no `players`, and routing it into the replacement
|
||||||
|
/// path presents zero slots to Core's empty-replacement guard, which correctly
|
||||||
|
/// refuses it — silently losing the user's captain/kick-taker change.
|
||||||
pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||||
let put = match parse_squad_put(body) {
|
match classify_squad_put(body) {
|
||||||
Ok(p) => p,
|
Ok(SquadMutation::Replace(put)) => handle_squad_replace(&put, deps),
|
||||||
Err(e) => {
|
Ok(SquadMutation::PatchRoles(patch)) => handle_squad_role_patch(&patch, deps),
|
||||||
return (
|
Err(e) => (
|
||||||
error_response(400, "parse_error"),
|
error_response(400, "parse_error"),
|
||||||
SquadLog {
|
SquadLog {
|
||||||
outcome: "parse_error",
|
outcome: "parse_error",
|
||||||
detail: e.to_string(),
|
detail: e.to_string(),
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/// The full-replacement path: reverse-resolve every wire id, AUTHORIZE every
|
||||||
|
/// resolved item against the active club, then commit the canonical squad +
|
||||||
|
/// FIFA extension to Core in one transaction. On any failure it returns an
|
||||||
|
/// error and NEVER falls back to Python (no double mutation).
|
||||||
|
fn handle_squad_replace(put: &Fifa17SquadPut, deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||||
// Reverse-resolve wire→owned and shape canonical + extension. Refuses on an
|
// Reverse-resolve wire→owned and shape canonical + extension. Refuses on an
|
||||||
// unresolved wire id or the same owned item placed twice.
|
// unresolved wire id or the same owned item placed twice.
|
||||||
let build = match build_squad_write(&put, deps.resolver) {
|
let build = match build_squad_write(put, deps.resolver) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(SquadBuildError::UnresolvedWireIds(ids)) => {
|
Err(SquadBuildError::UnresolvedWireIds(ids)) => {
|
||||||
return (
|
return (
|
||||||
@@ -2891,10 +3032,18 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
|||||||
// Persist the ownership-backed manager assignment (migration 0023). It was
|
// Persist the ownership-backed manager assignment (migration 0023). It was
|
||||||
// authorized above and Core re-validates club ownership; fail loudly on a
|
// authorized above and Core re-validates club ownership; fail loudly on a
|
||||||
// transport error rather than silently dropping the manager.
|
// transport error rather than silently dropping the manager.
|
||||||
if let Err(e) = deps
|
// Only written when this save actually carried an ownership-backed manager.
|
||||||
.core
|
//
|
||||||
.set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
|
// A save with no resolvable manager is NOT a request to remove the current
|
||||||
{
|
// one. FIFA 17 has no "remove manager" wire shape — the client always sends a
|
||||||
|
// ref — so `None` here means the ref was absent, zero, or unmappable, i.e.
|
||||||
|
// this save says nothing about the manager. Forwarding that absence as an
|
||||||
|
// explicit clear is exactly how a client whose squad model had been destroyed
|
||||||
|
// deleted a real manager row (WAL commit 468, squad_managers 1 -> 0), so the
|
||||||
|
// assignment is left untouched instead.
|
||||||
|
match build.canonical.manager_owned_card_id.as_deref() {
|
||||||
|
Some(mgr) => {
|
||||||
|
if let Err(e) = deps.core.set_squad_manager(mgr) {
|
||||||
return (
|
return (
|
||||||
error_response(502, "core_error"),
|
error_response(502, "core_error"),
|
||||||
SquadLog {
|
SquadLog {
|
||||||
@@ -2903,6 +3052,12 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
None => eprintln!(
|
||||||
|
"utas-host owner=RUST route=squad-replace manager_write_skipped \
|
||||||
|
(save carried no ownership-backed manager; existing assignment left unchanged)"
|
||||||
|
),
|
||||||
|
}
|
||||||
(
|
(
|
||||||
json_response(&save_ack(put.id)),
|
json_response(&save_ack(put.id)),
|
||||||
SquadLog {
|
SquadLog {
|
||||||
@@ -2912,13 +3067,160 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PUT …/squad/<n>` carrying NO `players` — the role-only patch path.
|
||||||
|
///
|
||||||
|
/// Observed real-client shape: `{id, custom, captain, kicktakers[5]}`, emitted
|
||||||
|
/// by the captain/kick-taker screen. Omission is the contract here: the absent
|
||||||
|
/// `players`, `manager` and actives mean UNCHANGED, never cleared. That is
|
||||||
|
/// enforced structurally — [`CoreRolePatchRequest`] has no field able to express
|
||||||
|
/// any of them, and Core's patch issues no statement that can touch them.
|
||||||
|
///
|
||||||
|
/// The extension is MERGED, not overwritten: the patch body carries only
|
||||||
|
/// `custom` and `kicktakers`, so kit numbers, squad type and the client-reported
|
||||||
|
/// evaluation are preserved from the stored copy. Overwriting would silently
|
||||||
|
/// drop every kit number in the squad.
|
||||||
|
fn handle_squad_role_patch(
|
||||||
|
patch: &Fifa17SquadRolePatch,
|
||||||
|
deps: &SquadDeps<'_>,
|
||||||
|
) -> (WireResponse, SquadLog) {
|
||||||
|
// Start from the stored extension so omitted FIFA-only state survives. A
|
||||||
|
// stale extension is still the right base: its kit numbers belong to the
|
||||||
|
// same squad, and this patch re-anchors the fingerprint on commit.
|
||||||
|
let read = match deps.core.read_squad_ext(EXT_NAMESPACE) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
error_response(502, "core_error"),
|
||||||
|
SquadLog {
|
||||||
|
outcome: "core_error",
|
||||||
|
detail: e.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut ext = match &read.ext {
|
||||||
|
CoreExtState::Fresh {
|
||||||
|
schema_version,
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
| CoreExtState::Stale {
|
||||||
|
schema_version,
|
||||||
|
payload,
|
||||||
|
} => Fifa17SquadExtensionV1::from_payload(*schema_version, payload).unwrap_or_default(),
|
||||||
|
CoreExtState::Missing => Fifa17SquadExtensionV1::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Carry the patch's own fields through. `custom` genuinely differs between
|
||||||
|
// the two shapes -- the role screen writes per-slot values into it -- so it
|
||||||
|
// is taken from the patch, not preserved.
|
||||||
|
if patch.custom.is_some() {
|
||||||
|
ext.custom = patch.custom.clone();
|
||||||
|
}
|
||||||
|
ext.kicktakers = patch
|
||||||
|
.kicktakers
|
||||||
|
.iter()
|
||||||
|
.map(|k| KicktakerRef {
|
||||||
|
index: k.index,
|
||||||
|
item: WireItemRef {
|
||||||
|
id: k.id,
|
||||||
|
dream: k.dream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Resolve + AUTHORIZE the captain before any write. Identity resolution is
|
||||||
|
// not authorization: a globally-valid wire id belonging to another profile
|
||||||
|
// must not become this club's captain.
|
||||||
|
let mut captain_owned_card_id = None;
|
||||||
|
if let Some(wire) = patch.captain.filter(|c| *c != 0) {
|
||||||
|
match deps.resolver.owned_id_for_wire(wire) {
|
||||||
|
Some(owned) => {
|
||||||
|
let owned_set: std::collections::HashSet<String> = match deps.core.all_owned() {
|
||||||
|
Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(),
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
error_response(502, "core_error"),
|
||||||
|
SquadLog {
|
||||||
|
outcome: "core_error",
|
||||||
|
detail: e.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !owned_set.contains(&owned) {
|
||||||
|
return (
|
||||||
|
error_response(403, "not_owned"),
|
||||||
|
SquadLog {
|
||||||
|
outcome: "unauthorized_captain",
|
||||||
|
detail: owned,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
captain_owned_card_id = Some(owned);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Refuse rather than silently patch the kicktakers alone: the
|
||||||
|
// user asked for a captain and would otherwise be told it
|
||||||
|
// succeeded while the captain never moved.
|
||||||
|
return (
|
||||||
|
error_response(400, "unresolved_captain"),
|
||||||
|
SquadLog {
|
||||||
|
outcome: "unresolved_captain",
|
||||||
|
detail: wire.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = CoreRolePatchRequest {
|
||||||
|
captain_owned_card_id,
|
||||||
|
ext_namespace: EXT_NAMESPACE.to_string(),
|
||||||
|
ext_schema_version: EXT_SCHEMA_VERSION,
|
||||||
|
ext_payload: ext.to_payload(),
|
||||||
|
};
|
||||||
|
if let Err(e) = deps.core.patch_squad_roles(&req) {
|
||||||
|
// A Core 400 means the REQUEST was invalid (e.g. a captain not fielded
|
||||||
|
// in this squad). Reporting that as 502 would blame the server for a
|
||||||
|
// client error and hide it behind "upstream unavailable".
|
||||||
|
let (status, code) = match e {
|
||||||
|
CoreError::Status(400) => (400, "invalid_role_patch"),
|
||||||
|
_ => (502, "core_error"),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
error_response(status, code),
|
||||||
|
SquadLog {
|
||||||
|
outcome: if status == 400 {
|
||||||
|
"invalid_role_patch"
|
||||||
|
} else {
|
||||||
|
"core_error"
|
||||||
|
},
|
||||||
|
detail: e.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=squad-roles captain={:?} kicktakers={} \
|
||||||
|
(players/manager/actives untouched)",
|
||||||
|
req.captain_owned_card_id,
|
||||||
|
ext.kicktakers.len()
|
||||||
|
);
|
||||||
|
(
|
||||||
|
json_response(&save_ack(patch.id)),
|
||||||
|
SquadLog {
|
||||||
|
outcome: "ok",
|
||||||
|
detail: String::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
|
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
|
||||||
/// integrity failures for the migrated dev profile: logged prominently, degraded
|
/// integrity failures for the migrated dev profile: logged prominently, degraded
|
||||||
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
|
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
|
||||||
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||||
match project_active_squad(deps) {
|
match project_active_squad(deps) {
|
||||||
HostProjection::Squad(v) => (
|
HostProjection::Squad { squad, .. } => (
|
||||||
json_response(&squad_list(&v)),
|
json_response(&squad_list(&squad)),
|
||||||
SquadLog {
|
SquadLog {
|
||||||
outcome: "ok",
|
outcome: "ok",
|
||||||
detail: String::new(),
|
detail: String::new(),
|
||||||
@@ -2955,8 +3257,8 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
|||||||
/// (never 401/403, never a Python fallback that could mask split authority).
|
/// (never 401/403, never a Python fallback that could mask split authority).
|
||||||
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
|
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
|
||||||
match project_active_squad(deps) {
|
match project_active_squad(deps) {
|
||||||
HostProjection::Squad(v) => (
|
HostProjection::Squad { squad, actives } => (
|
||||||
json_response(&user_mass_info_squad(v, persona_id)),
|
json_response(&user_mass_info_squad(squad, persona_id, actives)),
|
||||||
SquadLog {
|
SquadLog {
|
||||||
outcome: "ok",
|
outcome: "ok",
|
||||||
detail: String::new(),
|
detail: String::new(),
|
||||||
@@ -3081,8 +3383,8 @@ pub fn handle_user_mass_info(
|
|||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let (squad_val, log) = match project_active_squad(deps) {
|
let (squad_val, log) = match project_active_squad(deps) {
|
||||||
HostProjection::Squad(v) => (
|
HostProjection::Squad { squad, actives } => (
|
||||||
user_mass_info_squad(v, persona),
|
user_mass_info_squad(squad, persona, actives),
|
||||||
SquadLog {
|
SquadLog {
|
||||||
outcome: "ok",
|
outcome: "ok",
|
||||||
detail: String::new(),
|
detail: String::new(),
|
||||||
@@ -3535,6 +3837,8 @@ pub struct Server {
|
|||||||
economy_gate: Arc<Mutex<()>>,
|
economy_gate: Arc<Mutex<()>>,
|
||||||
/// Staging-only simulation of losing the successful SBC submit receipt.
|
/// Staging-only simulation of losing the successful SBC submit receipt.
|
||||||
sbc_post_commit_fault: SbcPostCommitFault,
|
sbc_post_commit_fault: SbcPostCommitFault,
|
||||||
|
/// Emit `squad.actives`. Default OFF; see `HostConfig::squad_actives`.
|
||||||
|
squad_actives: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
@@ -3559,6 +3863,7 @@ impl Server {
|
|||||||
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
|
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
|
||||||
economy_gate: Arc::new(Mutex::new(())),
|
economy_gate: Arc::new(Mutex::new(())),
|
||||||
sbc_post_commit_fault: SbcPostCommitFault::Off,
|
sbc_post_commit_fault: SbcPostCommitFault::Off,
|
||||||
|
squad_actives: false,
|
||||||
current_match: Arc::new(PlMutex::new(None)),
|
current_match: Arc::new(PlMutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3626,6 +3931,10 @@ impl Server {
|
|||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host sbc_post_commit_fault={:?}",
|
"utas-host sbc_post_commit_fault={:?}",
|
||||||
cfg.sbc_post_commit_fault
|
cfg.sbc_post_commit_fault
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"utas-host squad_actives={} (ON emits the club's active home/away kit so the client can make them resident; set OPENFUT_FIFA17_SQUAD_ACTIVES=0 to disable)",
|
||||||
|
cfg.squad_actives
|
||||||
);
|
);
|
||||||
Ok(Server::new(
|
Ok(Server::new(
|
||||||
core,
|
core,
|
||||||
@@ -3637,7 +3946,8 @@ impl Server {
|
|||||||
.with_economy(economy)
|
.with_economy(economy)
|
||||||
.with_clientdata(clientdata)
|
.with_clientdata(clientdata)
|
||||||
.with_account(account)
|
.with_account(account)
|
||||||
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault))
|
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault)
|
||||||
|
.with_squad_actives(cfg.squad_actives))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the shared squad dependencies (Core access + the one production
|
/// Assemble the shared squad dependencies (Core access + the one production
|
||||||
@@ -3647,6 +3957,7 @@ impl Server {
|
|||||||
core: self.core.as_ref(),
|
core: self.core.as_ref(),
|
||||||
resolver: self.resolver.as_ref(),
|
resolver: self.resolver.as_ref(),
|
||||||
entities: self.entities.as_ref(),
|
entities: self.entities.as_ref(),
|
||||||
|
squad_actives: self.squad_actives,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3672,6 +3983,14 @@ impl Server {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit `squad.actives`. Default OFF: a populated array makes the kits
|
||||||
|
/// resident but was observed to cost the rest of the squad (see
|
||||||
|
/// `HostConfig::squad_actives`).
|
||||||
|
fn with_squad_actives(mut self, on: bool) -> Self {
|
||||||
|
self.squad_actives = on;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
|
fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
|
||||||
self.sbc_post_commit_fault = fault;
|
self.sbc_post_commit_fault = fault;
|
||||||
self
|
self
|
||||||
@@ -4556,7 +4875,9 @@ impl Server {
|
|||||||
};
|
};
|
||||||
let deps = self.squad_deps();
|
let deps = self.squad_deps();
|
||||||
let (squad, squad_outcome) = match project_active_squad(&deps) {
|
let (squad, squad_outcome) = match project_active_squad(&deps) {
|
||||||
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
|
HostProjection::Squad { squad, actives } => {
|
||||||
|
(user_mass_info_squad(squad, self.persona_id, actives), "ok")
|
||||||
|
}
|
||||||
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
|
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
|
||||||
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
||||||
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
||||||
@@ -4940,6 +5261,28 @@ impl Server {
|
|||||||
json_status(200, &non_economy::feature_off_body())
|
json_status(200, &non_economy::feature_off_body())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The team whose kit this club currently wears, from its active kit items.
|
||||||
|
///
|
||||||
|
/// The pre-match kit clone resolves BOTH sides out of the client's own
|
||||||
|
/// `teamkits` table keyed on `teamtechid`, with only `teamkittypetechid`
|
||||||
|
/// distinguishing home from away. So if a season fixture names the club's own
|
||||||
|
/// kit team, the opponent renders the club's kit and both sides appear in
|
||||||
|
/// identical strips — which is also just wrong data, a club playing itself.
|
||||||
|
/// [`season_wire::season_list_body`] excludes this team from the schedule.
|
||||||
|
///
|
||||||
|
/// Best-effort: any Core hiccup yields `None` and the full rotation, which is
|
||||||
|
/// the pre-existing behaviour rather than a failed request.
|
||||||
|
fn own_kit_team_id(&self) -> Option<i64> {
|
||||||
|
let active = self.core.get_active_kits().ok()?;
|
||||||
|
let designated = active.home_owned_card_id.or(active.away_owned_card_id)?;
|
||||||
|
let page = self.core.query_owned(&[]).ok()?;
|
||||||
|
let item = page
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.owned_card_id == designated)?;
|
||||||
|
self.resolver.resolve_kit(item).map(|kit| kit.team_id)
|
||||||
|
}
|
||||||
|
|
||||||
/// `…/season…` — FIFA 17 offline Seasons.
|
/// `…/season…` — FIFA 17 offline Seasons.
|
||||||
///
|
///
|
||||||
/// The client will not open the mode until it has a schedule: `season/list`
|
/// The client will not open the mode until it has a schedule: `season/list`
|
||||||
@@ -4963,7 +5306,7 @@ impl Server {
|
|||||||
let (kind, body) = match sub {
|
let (kind, body) = match sub {
|
||||||
"list" => (
|
"list" => (
|
||||||
"list",
|
"list",
|
||||||
season_wire::season_list_body(SEASON_ID, DIVISION_ID),
|
season_wire::season_list_body(SEASON_ID, DIVISION_ID, self.own_kit_team_id()),
|
||||||
),
|
),
|
||||||
"user" => (
|
"user" => (
|
||||||
"user",
|
"user",
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1079,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
|
||||||
@@ -1150,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",
|
||||||
@@ -1179,11 +1260,16 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
|||||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
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);
|
||||||
@@ -1192,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(
|
||||||
@@ -1209,14 +1433,18 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
|||||||
"manager persisted as the Core owned id, never the wire id"
|
"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
|
||||||
@@ -1234,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(
|
||||||
@@ -1279,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",
|
||||||
@@ -1305,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(
|
||||||
@@ -1330,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",
|
||||||
@@ -1355,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);
|
||||||
@@ -1372,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",
|
||||||
@@ -1408,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",
|
||||||
@@ -1513,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(
|
||||||
@@ -1560,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",
|
||||||
@@ -1614,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");
|
||||||
@@ -1635,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");
|
||||||
@@ -1656,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},
|
||||||
@@ -1730,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",
|
||||||
@@ -2767,3 +3008,45 @@ fn no_shipped_training_card_exceeds_the_declared_ceiling() {
|
|||||||
}
|
}
|
||||||
assert_eq!(rare, 6, "all 6 rare all-six cards must resolve");
|
assert_eq!(rare, 6, "all 6 rare all-six cards must resolve");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collapsing every numeric `squad/<id>` onto one squad is safe ONLY while
|
||||||
|
/// exactly one squad is advertised. This is the tripwire for that assumption.
|
||||||
|
///
|
||||||
|
/// FIFA 17 has real multi-squad semantics — the client ships `SelectSquadById`,
|
||||||
|
/// `RetrieveSquad` (distinct from `LoadActiveSquad`), `CreateSquadWithName`,
|
||||||
|
/// `RenameSquad`, `DeleteSquad` and indexed `SQUAD_ID-%d` entries. We get away
|
||||||
|
/// with ignoring the id purely because the client can never learn another one:
|
||||||
|
/// the wire id is a constant 0 and `/squad/list` advertises a single squad.
|
||||||
|
///
|
||||||
|
/// If either fact stops holding, the numeric route needs a real lookup and this
|
||||||
|
/// test must be the thing that says so.
|
||||||
|
#[test]
|
||||||
|
fn numeric_squad_routing_is_safe_only_while_one_squad_is_advertised() {
|
||||||
|
assert_eq!(
|
||||||
|
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||||
|
0,
|
||||||
|
"the single advertised squad id is what makes id-collapsing safe"
|
||||||
|
);
|
||||||
|
|
||||||
|
let projected = serde_json::json!({
|
||||||
|
"id": openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||||
|
"squadName": "OpenFUT",
|
||||||
|
"formation": "f442",
|
||||||
|
"squadType": "REGULAR_SQUAD",
|
||||||
|
"rating": 90,
|
||||||
|
"chemistry": 52,
|
||||||
|
});
|
||||||
|
let list = openfut_adapter_fifa17::fut::squad_projection::squad_list(&projected);
|
||||||
|
let squads = list["squad"].as_array().expect("squad list is an array");
|
||||||
|
assert_eq!(
|
||||||
|
squads.len(),
|
||||||
|
1,
|
||||||
|
"more than one advertised squad means the client can address a second \
|
||||||
|
id, and every numeric GET/PUT collapsing onto one squad becomes wrong"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
squads[0]["id"],
|
||||||
|
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||||
|
"the advertised id must be the one the client echoes back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+28
-16
@@ -182,9 +182,15 @@ DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
|
|||||||
# Two authoritative modern kit-card definitions for one real source team. These
|
# 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
|
||||||
@@ -701,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"
|
||||||
@@ -712,7 +725,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
|||||||
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
|
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)")
|
||||||
@@ -983,7 +996,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
|||||||
# calling a kit a player, and Core is the ownership authority.
|
# 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(
|
||||||
(
|
(
|
||||||
@@ -996,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:
|
||||||
@@ -1010,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, "
|
||||||
@@ -1034,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
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user