10 Commits

Author SHA1 Message Date
funman300 a3fd51692f fifa17-recon: the trading gate is still shut, and two of yesterday's conclusions were wrong
Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.

REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.

REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.

AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
    settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
      -> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.

The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.

Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
  vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e        vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
  vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
  vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d      vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
  vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40  vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.

WHAT IS ACTUALLY SHIPPED HERE, all default off:
  * FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
    INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
    in _item(), because the save holds 246 items minted before the flag existed and the
    club route serves them straight from the save. That gap was caught by reading the
    served JSON, not by unit-testing the factory.
  * FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
    only as a record of the refutation, with the reasoning inline so nobody retries it.
  * fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
    not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
    block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
    preferred could never have answered this for three of five families.
  * Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
    (which was never bisected; value is the established suspect and that response also
    carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
    never copied into the record.

Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:47:02 -07:00
funman300 e578443d73 fifa17-recon: tradingEnabled is 0, and that is why the transfer options are greyed out
Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in
docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I
committed earlier today.

THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on
Transfer Market" have been disabled in the reveal screen and nobody knew why.
TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at
vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is
the tradingEnabled gate byte. Read live and reproduced independently:

  slot +0x2b0 friendlySeasons  disp 0x1fd3a  VALUE=1
  slot +0x2c8 draftMode        disp 0x1fd3d  VALUE=1
  slot +0x2e0 packOpeningAnim  disp 0x1fd45  VALUE=1
  slot +0x270 tradingEnabled   disp 0x1fd2e  VALUE=0

tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates
the settings work from this morning: that plan died because every gate it targeted
already read 1, and the conclusion drawn was that the settings array does not matter.
It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in
_SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off.

So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping
only the boolean would look like the finding failed.

THE DISCARD "MISS" NEVER EXISTED, which corrects e3092ca. fcc_discardcoins is resident
and complete, the client lookup runs and is correct, and it lands at item+0x3c. The
tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the
client was not failing a lookup; it was faithfully displaying the 0 we sent. Same
observable, completely different mechanism, and the version in e3092ca is wrong.
FUT_DISCARD_SEND remains exactly the right fix, now for the right reason.

WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the
table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own
+0x3c reads 36. That closes the gap I flagged in e3092ca as not-guessed.

CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9,
stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination.
All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in
the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have
answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent
a launch and returned nothing for three of five families.

THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it
lands on and whether the client keeps it. Built by diffing what we serve against the
parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer
rather than by assuming the +0x38 offset). Corrections that change what we serve:
+0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is
itemState, definitionId is not an atom at all.

A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at
+0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the
control was the wrong FORM. That is a new variant of the absence trap, which has now
cost six wrong verdicts, and it is recorded in the doc.

FIX TO MY OWN PATCH from e3092ca: purchased() and last_pack() lacked the _with_discard
wrapper that items() had, so the pending pile, which is the one place a quick-sell
value is actually read, served unstamped cards. Found by verification, not testing.
All three read paths now stamp.

Correcting an overstatement in e3092ca: "turning the flag off is a true revert" holds
for the read paths, which copy, but NOT for cards minted while armed, because _item()
stamps at creation and those persist (9 items currently). Kept deliberately: the pack
reveal serves itemList straight from open_pack(), not through purchased(), so removing
creation-stamping would leave the screen that matters unstamped. Persisted values are
correct and self-heal, since every read recomputes and overwrites.

Nothing here has been on screen. Six patches are proposed in the doc as pasteable text,
env-flagged, defaulting off, none applied.

Live: 439 contract checks, 414 card-family checks, market suite, all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:07:01 -07:00
funman300 e3092ca0f9 fifa17-recon: the client now shows the quick-sell value it is actually paid
Follow-on to 21a81ad, both halves confirmed live.

FUT_DISCARD_TABLE IS PROVEN. Quick selling a 75-rated rare gold paid 600 and the
balance moved 9,844,900 -> 9,845,500, exact. The invented tier would have paid 150.
75 * 800 / 100 = 600, straight off the recovered fcc_discardcoins row.

BUT THE SCREEN SAID 0, which is why this commit exists. The value was right and
invisible: "Quick Sell 0" on the card and "Quick Sell all remaining Items 0" too, so
the wallet contradicted the display on every card. Cause is the guard the table work
had already reversed. FUN_18013fe00 stores our discardValue (atom 0xd7) at item +0x38
and 0x180141025 skips the client's own fcc_discardcoins lookup only when that value is
NON-ZERO. We seeded 0, so the client ran its own lookup, that lookup returns no row for
our cards, and it rendered 0.

FUT_DISCARD_SEND puts the value on the wire and the client uses ours verbatim. Live
result, one launch:
    a 77-rated rare gold shows "Quick Sell 616"   (77 * 800 / 100 = 616, exact)
    "Quick Sell all remaining Items" shows 5,640
and 5,640 is exactly the sum of the ten rated PLAYER cards in the pending pile. The
eleventh, a consumable, is excluded by the client from the bulk figure; the twelfth is
a staff card we deliberately send no value for. Both halves of the display now agree
with what the server credits.

WHY THE CLIENT'S OWN LOOKUP MISSES for our cards is still UNKNOWN. This routes around
that question rather than answering it, and it is worth answering.

TWO CORRECTNESS FIXES THE REPORT DID NOT COVER, both found by running the whole save
through the formula rather than trusting the 22/22 sample:
  * The recovered formula scales by rating, so a rating-less STAFF card collapses to 0.
    The old tier paid 50, so shipping it as-is was a regression. discard_value() now
    returns None when the formula does not apply and callers fall back. What FUT really
    pays for staff and consumables is UNKNOWN; the likely answer is the unscaled table
    price, but that is a guess and is not shipped as one.
  * A missing table row also returns None rather than 0, for the same reason.
  Verified across all 246 club items plus the pending pile: not one pays 0 coins.

discardValue is stamped inside the single item factory so every path gets it (pack
contents, starter grant, club, market), and additionally on the club READ path, because
_item() only covers cards minted from now on while the save already holds 246 built
before the flag existed. Stamped on the way out and NOT persisted, so the save stays
clean and turning the flag off is a true revert. Confirmed: 0 items on disk carry the
key.

FUT_DISCARD_SEND requires FUT_DISCARD_TABLE and silently stays off without it, so the
invented tier can never reach the screen and become authoritative-looking.

Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
scalar getter 0x1801c79d0; every freeze on this project has come from an object or
array where a scalar was expected, never the reverse.

Both flags still default OFF. Live: 439 contract checks pass, market suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:55:05 -07:00
funman300 21a81ad63c fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer
Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.

THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:

    value = round_half_up(rating * price / 100)
    level    = 3 if rating >= 75, 2 if 65..74, else 1   (0x180141e8a..0x180141ea3,
               derived from rating, NOT a wire field)
    cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
               across every subtype 0..599 with zero disagreements

A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.

This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.

Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.

ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.

THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.

The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.

extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.

A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.

Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.

Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.

Live: 439 contract checks pass, market suite passes, both flags off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:43:51 -07:00
funman300 3f3d5704a7 fifa17-recon: quick sell has never been reachable, and finalFunds is the rendered price
Live run, 2026-08-05 evening. Three results, one of them a route that has been dead for
the whole life of the project.

QUICK SELL WAS NEVER SERVED. Captured on the wire:
    DELETE /ut/game/fifa17/item/100000240      (single card, id in the URL, no body)
ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, ROUTES was built from the
doc, and the regex therefore never matched a real quick sell. Every quick sell fell
through to the catch-all, so quick_sell_route() and STORE.quick_sell() behind it had
never once been called.

The empty response was not a harmless no-op. This body is BALANCE-BEARING: the client
takes its coin total from it, so with totalCredits absent it rendered an uninitialised
value. A real session showed 1,133,686,384 coins against a true balance of 9,889,600.
Display artifact only, corrected by the next GET /user/credits, and the save was never
touched, but it is the reason a stub is not acceptable here.

quick_sell_url_route() serves the real form and returns the corrected shape,
{"items":[{"id":N}],"totalCredits":N}. DEFAULT ON, which the house rule now permits:
two quick sells fired through it in one session, each credited 150 and removed the
card, and the coin arithmetic reconciled exactly against the 15,000 pack purchases
either side of them. An unknown id returns {"items": []} rather than claiming a sale we
cannot account for.

Still UNKNOWN and deliberately not chased: whether the client ASSIGNS totalCredits as
the new balance or ADDS it as a delta. We send the new balance. The discriminating
window is about five seconds wide, because the client refetches GET /user/credits
straight afterwards, so either reading self-corrects and the practical impact is a brief
wrong number. It mattered only while we answered {}, because that garbage persisted.
The credit amount is STORE.quick_sell()'s invented rating tier, not FUT's real discard
table, which remains unknown.

finalFunds IS THE RENDERED COIN PRICE. Served funds=15000 / finalFunds=4321 on one pack
and the tile read 4,321. funds is not displayed. ENDPOINT_MAP updated to CONFIRMED LIVE
with the method recorded. FUT_PRICE_PROBE, the flag that produced it, stays default off
and is disarmed: it puts a price on a tile that the buy path does not charge.

TWO UNPLANNED FINDINGS, both recorded for the next round rather than fixed here:
  * The store grouping is broken and it is NOT cosmetic. All three packs collapse into
    one display group, and the Bronze, Gold and Premium group tiles all drill into the
    same single Premium Gold pack, so TWO OF THREE PACKS CANNOT BE BOUGHT. We send
    displayGroup {"value": name} but never displayGroupAssetId (0xda), so everything
    lands in group 0. The docs had this parked as a cosmetic "tiles read unknown"
    issue; it is an availability bug.
  * The FIFA Points price renders as the literal "or %1s", an unsubstituted printf
    placeholder. extPrice.finalPrice is served as {"amount":N,"currency":"mtx"} and
    "mtx" is evidently not a currency token the client resolves. Cosmetic.

Corrected in passing: packContentInfo DOES reach the tile (11 ITEMS / 11 GOLD /
11 RARES against exactly what we serve). An earlier screen showing zeros was the
display-GROUP level, which carries no content info. D3 was right.

Live: 439 contract checks pass, both probe flags off, store prices back to honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:03:41 -07:00
funman300 89da7b7609 fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.

THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.

The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.

Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.

HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.

Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.

TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.

ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
  * duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
    list at :1095. Bare ints where the element parser expects objects is a tokenizer
    desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id.

No behaviour change. utas_server.py is comment-only. 439 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:35:27 -07:00
funman300 1605e6effd fifa17-recon: the envelope rule, and the one key of the auth body that is silently eaten
The open question was whether a response deserializer DESCENDS a wrapper or PROBES for
one. Three roots spend an identical three tokenizer calls before dispatch, yet we serve
some bodies wrapped and some flat, and not all of those could be right.

TOKEN ENUM, decoded from the class table at DAT_18023dd40 and the switch in the
classifier FUN_1801c67a0 (the push/pop arms key off container state 2 = object,
3 = array):

  9  START_OBJECT      case 0x64, pushes state 2
  10 END_OBJECT        case 0x65, pops state 2
  11 FIELD_NAME        confirmed independently: FUN_18013bd40 tests +0xd0 == 0xb then
                       atom-hashes the string at +0xf8
  12 START_ARRAY       case 0x66, pushes state 3
  13 END_ARRAY         case 0x67, pops state 3
  1  error             the caseD_78 sink

So the three tokens are `{`, the first FIELD_NAME, and the token opening that field's
value. The envelope is structurally required and its name is NEVER hashed, which is why
FutCreatePack's ladder has no arm for createPackResponse (0xbe) and does not need one.
Coverage for that absence: the ladder has exactly four arms (0xec, 0x16e, 0x1dd, 0x264)
and 0xbe does not occur anywhere in the full 4702-char decompile, printed in full.

The competing reading rested on a factual error. It claimed the /purchased root spends
the same three tokens. FUN_180124ee0 spends TWO and hands off to FUN_18013bd40, which
spends the third. Same total, split across two functions. /purchased never was a
counterexample.

THE BUG THIS FOUND IS NOT THE ONE THAT WAS PREDICTED. The doc expected starterPack,
squad and userData to be swallowed on POST /user. They are not. FutCreateUser
(0x18014cc60) has ladder arms for exactly the five keys we send, and four of them
dispatch correctly at the outer level. The one that does not is `login`: its name is
eaten as the anonymous envelope and its value as the third token. It has an arm, so the
client wants it, and it has never once been delivered.

The second-order consequence matters more than the first. The key order of that dict is
load-bearing and nothing said so. Put userData first and the client loses the entire
user record, silently, with no error and no log line. That warning now sits in the code
next to the dict, which is the only place someone about to reorder it would look.

No behaviour change here. The utas_server.py edit is a comment. 439 contract checks
still pass. The probable proper fix, wrapping all five keys one level down inside a
single envelope key, is a hypothesis with a mechanism rather than a proven fix, and it
touches the login path, so it is not made here and would go behind a flag defaulting
off.

Writeup is section 2 of docs/plan-2026-08-05-pack-opening.md, added by the previous
commit. Opened by this and still UNKNOWN: GET /hub is answered with a flat two-key
body, which under this rule a three-token root would silently truncate, but there is no
FutGetHubServerResponse class and neither atom has a code xref, so /hub may not go
through a generated root at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:25:01 -07:00
funman300 afdbb364ca fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.

THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.

Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
  * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
    0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
    int list documented at :1095 and :218. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
    inner object loop. We serve [], so this is a docs bug today and a live freeze the
    moment somebody implements it from the map as written.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id. :968-971 is wrong twice over.

packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.

The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.

Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.

futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.

utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.

Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:37 -07:00
funman300 d0dbfa99c0 fifa17-recon: the /settings gate bytes were never zero, and the plan built on that is dead
Yesterday's settings-gate plan asserted that IS_FRIENDLY_SEASON_ENABLED and
IS_DRAFT_MODE_ENABLED "have never been set to true by anything, on any run", and
proposed spending a launch on that premise. Measured against the running client,
both read 1, and so does packOpeningAnimationEnabled, while /settings has only ever
been answered {"configs": []}.

  disp 0x1fd3a (friendlySeasonsEnabled)      value = 1
  disp 0x1fd3d (enableDraftMode)             value = 1
  disp 0x1fd45 (packOpeningAnimationEnabled) value = 1

Reproduced on two separate launches and two different pids.

Where the reasoning went wrong: the finding that FUN_18011dc50 is the only writer of
those bytes, and that the FutDataManagerImpl constructor never touches them, was
correct. The inference was not. The applier runs whether or not the configs array has
content, and the struct it is handed defaults these fields to 1, so the bytes were
being written all along. "Nothing populates the array" was treated as "nothing writes
the byte". Only the first of those was ever established.

Seasons therefore does not refuse because its gate byte is false. Its gate byte is
true. That diagnosis restarts, and the live test in section 3 should not be run as
written. The doc keeps the wrong turn on the record rather than quietly deleting it.

tools/gate_byte_probe.py makes this repeatable instead of a one-off. It is read-only
(O_RDONLY + pread), resolves the pid by comm, re-derives the CardsDLL slide from
/proc/<pid>/maps rather than caching it across launches, proves the slide against the
FNV prologue at 0x180180d00 read from the on-disk PE before trusting any address, and
decodes each gate displacement out of its accessor stub (0f b6 81 <disp32>) rather
than reading it from a table. Needs the client at the FUT hub, since CardsDLL loads
only then.

Also carries the two /settings changes that were pending from before: the mode
defaults to `off` (the live-proven baseline, since nothing here has faced the game)
and the transfer-pile probe is 77 rather than 100, because 100 is a stock-looking
number that would prove nothing if it showed up in game.

Live: 439 contract checks pass. check_settings_flags.py passes in all four modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:02 -07:00
funman300 897259c8fb fifa17-recon: the /settings 42-flag gate, and why Seasons never asks
ENDPOINT_MAP said this class reads one key, `configs`, and that was true and
useless. What it missed is what happens after each element closes: the client
feeds the STRING VALUE of `type` back through the atom hasher and switches on
the result, 42 arms wide. A flag is a row, not a key, and the client hashes our
string itself.

Followed it to the end. FUN_18011dc50 is the only writer of the IS_* UI gate
bytes inside FutDataManagerImpl, every line is `byte = (field == 1)`, and the
constructor never touches those bytes. So a flag nobody sends is a gate nobody
opens. friendlySeasonsEnabled and enableDraftMode have never been sent by
anything, which is a mechanism for Seasons refusing while making zero requests
to any of the four servers.

The store is the control that makes this readable: IS_STORE_ENABLED is the same
kind of byte and its screen works, because storeEnabled and friends already
ship through the Blaze config store. That list has no seasons or draft flag.

Ship the gates behind FUT_SETTINGS (off/keep/gates, default gates), and
re-assert the working store flags in the same array on purpose: once a
populated array makes the applier run, it writes EVERY gate byte, so omitting
them could switch off a screen that works today.

maximumTradePileSize=100 rides along as a positive control, because a boolean
that changes nothing cannot distinguish "the flag did not help" from "the array
never reached the consumer".

check_settings_flags.py asserts each shipped name against the atom table AND
the recovered switch, since a misnamed flag is silently inert and looks exactly
like a failed fix. enableSquadBuildingSetsFeature is the reason both checks are
needed: a real atom with no arm here.

Live: 439 contract checks pass, market unit suite passes.
Not yet tested in game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:11:20 -07:00
242 changed files with 23014 additions and 33 deletions
+88 -9
View File
@@ -215,7 +215,11 @@ Path template `%s = "game/fifa17"`. Methods inferred from struct verb + endpoint
### Freeze-risk summary (type fidelity is mandatory)
- `auctionInfo` → **array** (never object/scalar).
- `itemData` inside each record → **object** (the card; reuse `item_def`).
- `duplicateItemIdList` → **array**.
- `duplicateItemIdList` → **array of objects** (element deser `0x180138e10`: `itemId` 0x16d,
`duplicateItemId` 0xeb, `itemLoans` 0x16f, `duplicateItemLoans` 0xed). Not an int list.
`[]` is safe; a list of bare ints is a freeze. Control that this is not a misread:
`dreamSquads` 0xe9 in FutMoveCard genuinely IS a bare int array, parsed by a
`while (tok != 0xd)` loop calling the int getter with no inner object loop.
- `bidState`, `tradeState`, `sellerName` → **strings**.
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
- `watched` → **bool**.
@@ -966,7 +970,11 @@ Notes:
{ "itemData": [ /* the single updated card item */ ] }
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 }
// CORRECTED 2026-08-05: `items` is an array of OBJECTS and there is no top-level `id`.
// The previous shape, { "items": [ 123456789 ], ..., "id": 123456789 }, was wrong twice
// over, and feeding a bare int where the element parser expects an object is a tokenizer
// desync, i.e. a hard freeze at 0x1801c7f1a, not a soft failure.
{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 }
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
{ "totalCredits": 15000 }
@@ -1042,7 +1050,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk) |
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk). **`finalFunds` is the number the tile RENDERS. CONFIRMED LIVE 2026-08-05** by serving `funds=15000, finalFunds=4321` on one pack and reading `4,321` off the store tile. `funds` is not displayed. |
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) |
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
| `sortPriority` | 0x2cb | INT | |
@@ -1092,7 +1100,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
| `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk |
| `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | freeze-risk |
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
- **Minimal known-good**:
@@ -1243,12 +1251,83 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze).
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED
- **Deser:** `0x18013c6d0`
- **HTTP:** `GET ut/%s/settings`
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED (schema) / the 42 flags are RECOVERED, UNTESTED
- **Deser:** `0x18013c6d0` (1982 bytes, 12061-char decompile, read end to end)
- **HTTP:** `GET ut/%s/settings`, and the `settings` (0x2bf) member of `userMassInfo`
(both callers of the deser: `0x18014e590` and `0x180174630`)
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
`{ type (0x354), value (0x377) }`.
- **Handled:** `utas_server.SETTINGS = {"configs": []}`. Min JSON: `{"configs":[]}`.
`{ type (0x354), value (0x377) }`. The key ladder really does hold nothing else.
**The mechanism the key ladder hides.** A flag is not a JSON key. When an element
closes, the client feeds the STRING VALUE of `type` back through the atom hasher
(`FUN_180180d00`) and switches on the result, 42 arms wide:
```json
{"configs": [{"type": "friendlySeasonsEnabled", "value": 1}]}
```
So the flag vocabulary is the same atom table everything else uses, and the client
hashes our string itself — a flag cannot be misnamed silently, it simply falls
through to the default arm and is ignored.
- **`value` is type-forgiving.** Its getter `0x1801c79d0` accepts int (token 2),
float (3), bool (4) and string (5, via `sscanf "%I64d"`), coercing all four to
int64. `1`, `"1"` and `true` are equivalent. This is one of the few scalar
getters in the API with NO desync risk on scalars. An object or array is still
a freeze.
- **The applier demands exactly 1.** `FUN_18011dc50` is the only writer of the
gate bytes and every line is `gate_byte = (field == 1)`. Not truthiness. `2`,
`-1` and `"yes"` all read as OFF.
**Flags that publish a UI gate key.** `FUN_18006cc60` publishes IS_* state keys by
reading single bytes inside `FutDataManagerImpl` (service id `0xed84b11`, ctor
`0x18010cdc0`). Those bytes are written ONLY by the applier, and the ctor never
touches them (whole 16620-char ctor scanned):
| flag `type` | field | gate byte | UI key |
|---|---|---|---|
| `tradingEnabled` | `[10]` | `0x1fd2e` | `IS_TRADING_ENABLED` |
| `storeEnabled` / `_JP` | `[0xb]` / `[0xc]` | `0x1fd2f` / `0x1fd30` | `IS_STORE_ENABLED` (accessor `0x18011c600` picks `_JP` when region == 4) |
| `friendlySeasonsEnabled` | `[0x16]` | `0x1fd3a` | `IS_FRIENDLY_SEASON_ENABLED` |
| `tournamentQuitEnabled` | `[0x20]` | `0x1fd3b` | `IS_TOURNAMENT_QUIT_ENABLED` |
| `processingStateEnabled` | `[0x21]` | `0x1fd3c` | `IS_PROCESSING_STATE_ENABLED` |
| `enableDraftMode` | `[0x17]` | `0x1fd3d` | `IS_DRAFT_MODE_ENABLED` |
| `enableOfflineDraftMode` = `enableSinglePlayerDraftMode` | `[0x18]` | `0x1fd3e` | (shared arm, one field) |
| `storyModeRewardEnabled` | `[0x1f]` | `0x1fd3f` | `IS_STORY_MODE_REWARD_ENABLED` |
| `returningUserRewardsScreenEnabled` | `[0x19]` | `0x1fd40` | `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED` |
**Why this is the standing suspect for Seasons and Draft.** Both refuse while
making zero requests to any of the four servers, which no response shape can
explain. A UI key evaluated from a byte that nothing ever wrote does explain it.
The store is the control: `IS_STORE_ENABLED` reads the same kind of byte and its
screen works, because `storeEnabled` and friends are already shipped through the
**Blaze** client-config store (`FUT_RS4_CONFIG` in `blaze_responder_v3b.py`) —
and that list contains no seasons, draft or tournament flag. Same mechanism, one
population, one blank.
This is a hypothesis with a mechanism, not a confirmed cause. It predicts that
sending the flags opens the screens; if they still refuse, the gate is upstream
of the UI key and the whole settings line is dead.
**Two arms that are not simple assignments:**
- `enableObjectives` (0xfd) and `enableObjectivesAsManagerTasks` (0xfe) share an
arm that can only ever CLEAR `[0x1c]`: `if (value == 0) field = 0`. Sending 1
is a no-op. Objectives cannot be turned ON here, only off.
- `clientKeepAliveResetTimeoutSec` (0x86, vtable +0x68) and `getOperationTimeoutSec`
(0x13d, +0x58) do not store a field; they call a timer object with `value * 1000`.
Sending a small number shortens client timeouts. Leave them alone.
**`maximumTradePileSize` (0x1c0) is the positive control.** It lands in `[0]` and
is passed to `FUN_18011f380`, and transfer-list capacity is visible in game. It
distinguishes "the flag did not help" from "the configs array never reached the
consumer at all", which no boolean flag can do on its own.
**Not in the switch:** `enableSquadBuildingSetsFeature` (0x100) is a real atom but
has NO arm here, so SBC is gated somewhere else. Scanned the full decompile;
this absence is asserted over the whole function, not a slice.
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
`off` restores the historical `{"configs": []}`.
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
# The /settings feature gate - live-test script
> **CORRECTION, 2026-08-05 evening. Section 1 of this document is FALSE and the
> test in section 3 should not be run as written.**
>
> Section 1 claims `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have
> never been set to true by anything, on any run". They are measured as **1**, on
> two separate launches, while `/settings` was answering `{"configs": []}`:
>
> ```
> disp 0x1fd3a (friendlySeasonsEnabled) value = 1
> disp 0x1fd3d (enableDraftMode) value = 1
> disp 0x1fd45 (packOpeningAnimationEnabled) value = 1
> ```
>
> Reproduce with `tools/gate_byte_probe.py` (needs the client at the FUT hub, since
> CardsDLL loads only then): it resolves the pid by comm,
> re-derives the CardsDLL slide from `/proc/<pid>/maps`, proves it against the FNV
> prologue at `0x180180d00` read from disk, walks the model singleton at
> `DAT_1802e6398`, and decodes each displacement out of its accessor stub
> (`0f b6 81 <disp32>`) rather than assuming it.
>
> **Where the reasoning went wrong.** The finding that `FUN_18011dc50` is the only
> writer and that the `FutDataManagerImpl` constructor never touches those bytes was
> correct. The inference drawn from it was not. The applier runs whether or not the
> configs array has content, and the settings struct it is handed defaults these
> fields to 1, so the bytes were being written all along. "Nothing populates the
> array" was treated as "nothing writes the byte". Those are different claims and
> only the first one was established.
>
> Seasons therefore does not refuse because its gate byte is false. Its gate byte is
> true. The mechanism is still unknown and needs a fresh diagnosis. Everything below
> the correction is kept as the record of a wrong turn, not as a plan.
Written 2026-08-05, after reversing `FutGetSettingsServerResponse` end to end.
Nothing here has been in front of the game yet. The code default is `off`, which
serves the exact historical `{"configs": []}`, so the tree is currently at the
proven baseline and this test is opt-in.
Full schema, atom ids, gate bytes and accessor addresses are in `ENDPOINT_MAP.md`
under `FutGetSettingsServerResponse`. This file is only the experiment.
---
## 1. The claim being tested
`GET /settings` is requested 11 times a session and has always been answered with
an empty array. The array is not decoration:
- Each element is `{"type": "<name>", "value": <scalar>}`. The client hashes the
**string value** of `type` through the atom hasher and switches on it, 42 arms
wide, so a flag is a row rather than a key.
- `FUN_18011dc50` is the **only** writer of the `IS_*` UI gate bytes inside
`FutDataManagerImpl`, and every line of it is `byte = (field == 1)`.
- The `FutDataManagerImpl` constructor never touches those bytes. The whole
16620-char decompile was scanned for the block; it is absent.
So `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` have never been set
to true by anything, on any run, in the whole history of this project.
That is a mechanism for the standing bug in which **Seasons refuses while making
zero requests to any of the four servers.** No response shape could ever explain
that. A UI key evaluated from a byte nobody wrote does.
**The store is the control that makes this readable.** `IS_STORE_ENABLED` is the
same kind of byte read the same way, and the store screen works. It works because
`storeEnabled` and its siblings already reach the client through the **Blaze**
client-config store (`FUT_RS4_CONFIG`). That list contains no seasons flag, no
draft flag, no tournament flag. Same mechanism, one populated, one blank.
This is a hypothesis with a mechanism, not a demonstrated cause.
---
## 2. Pre-flight, from the terminal, costs nothing
```bash
cd fifa17-recon/tools
FUT_SETTINGS=gates python3 check_settings_flags.py # expect: 14 rows, PASS
python3 check_settings_flags.py # expect: mode=off, PASS
```
The checker asserts every shipped flag name against **both** the atom table and
the recovered switch arms. Both are needed: `enableSquadBuildingSetsFeature` is a
genuine atom with no arm in this switch, so the atom table alone would wave
through a flag that does nothing. A misnamed flag is silently inert and looks
exactly like a failed fix, which is the failure mode this guards.
---
## 3. The run
Budget: **one launch.**
```bash
cd fifa17-recon/tools
FUT_SETTINGS=gates ./openfut-fut.sh start
~/Desktop/launch-fifa17.sh
```
Then, in order, and write down what each one does:
1. **Store.** Open it. This is the control and it goes first, because if
populating the array broke the store then the applier demonstrably ran and
everything after this reads differently.
2. **Transfer list capacity.** Transfers → Transfer List. Read the capacity
number. We send `maximumTradePileSize = 77`, a number FUT would never choose
on its own.
3. **Seasons.** Single-player Seasons, the exact path that has been refusing.
4. **FUT Draft.** Both the offline and online entries.
5. **Tournaments**, for `tournamentQuitEnabled`.
---
## 4. Reading the result
The control in step 2 is what makes a negative result informative, so read it
before concluding anything about steps 3 to 5.
| Store (1) | Capacity (2) | Seasons (3) | Reading |
|---|---|---|---|
| works | **77** | opens | Confirmed. The gate was the empty array. Make `gates` the default and move to the `/match` shape, which has been blocked behind this. |
| works | **77** | still refuses | The array reached the consumer and the flag was applied, so the gate is **upstream of the UI key**. The settings line is then dead for Seasons and the next move is a live probe of the refusal path, not more response work. This is a real result, not a null one. |
| works | not 77 | still refuses | The array never reached the consumer at all. Everything above is untested rather than refuted. Suspect the massinfo `settings` member (the deser's other caller) is what the client actually reads, and check which of the two paths fires in `/tmp/utas.log`. |
| **breaks** | any | any | The applier ran and re-asserting the store flags did not hold them. Fall back to `FUT_SETTINGS=keep`, which sends only the already-working flags plus the control. If `keep` also breaks the store, populating the array is harmful in itself and the whole approach is wrong. |
`keep` exists precisely so that "populating the array at all" and "the new gates"
can be separated without guessing, and it costs one restart to use.
---
## 5. What would make this whole plan wrong
**The gate might not be a UI key at all.** Seasons could be refusing on an
entitlement, a persona attribute, or a Blaze session property evaluated inside
the Denuvo-packed executable, in which case no `/settings` body reaches it. The
step-2 control is what tells these apart: it distinguishes "the flag did not
help" from "the array was never consumed", and no boolean flag can do that alone.
**The store control could be weaker than it looks.** The argument assumes
`IS_STORE_ENABLED` currently comes from the Blaze store rather than from a
default. If it turns out the store screen does not read that key at all, then it
is not a control for anything and the reasoning in §1 loses its anchor.
**Draft has a second known suspect.** `GET ut/%s/squad/mode/draft/state` is still
answered by the generic `/squad` handler with a full active-squad object, which
is a textbook type-desync candidate. If Draft still fails while Seasons opens,
that route is the next thing to look at, not the flag.
**A negative result here is worth having.** The settings array has been the
standing suspect for the greyed-out entry points for two rounds without anyone
sending a single flag. Ruling it out costs one launch and removes it from the
backlog permanently.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
/target
+16
View File
@@ -0,0 +1,16 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "futmem"
version = "0.1.0"
dependencies = [
"memchr",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "futmem"
version = "0.1.0"
edition = "2021"
description = "Read-only live-memory inspector for the FIFA 17 process (preservation / reverse-engineering tooling)"
publish = false
# An EMPTY [workspace] table makes this crate its own workspace root.
# Without it, cargo walks up the directory tree, finds
# /home/alex/Documents/OpenFUT/Cargo.toml, sees that futmem is not in its
# `members` list, and refuses to build. That parent manifest is untracked and
# must not be edited, so we opt out from this side instead.
[workspace]
[dependencies]
# memchr is the ONLY dependency, and it earns its place.
# A `find` sweep covers roughly 3 GB of resident memory. The naive
# `windows(n).position(...)` search runs at a few hundred MB/s; memchr's
# memmem uses SIMD (AVX2 on this box) and runs an order of magnitude faster,
# which turns a multi-minute sweep into a few seconds.
# Everything else (argument parsing for four subcommands, /proc/<pid>/maps
# parsing, hex dumping) is a few dozen lines of std and does not justify
# pulling in clap or a proc-maps crate.
memchr = "2"
[profile.release]
opt-level = 3
+249
View File
@@ -0,0 +1,249 @@
# futmem
A small, read-only live-memory inspector for FIFA 17, built for the OpenFUT
preservation project.
`FIFA17.exe` is Denuvo-packed: its `.text` and `.rdata` exist in plaintext only
inside the running process. Anything the packed executable owns can be reached
only through live memory. `CardsDLL_Win64_retail.dll`, which holds nearly all the
FUT logic, is unpacked but is loaded at a different address on every launch.
`futmem` answers both problems: it finds the process, tells you where everything
is loaded, and lets you search and dump it without touching a byte.
```
cargo build --release
./target/release/futmem maps
```
## Read only by construction
A live game session may be running while this tool is used, and corrupting it
costs the user their session. The read-only property is therefore structural
rather than a matter of discipline:
* `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The identifier
`OpenOptions` does not appear anywhere in this crate.
* `ProcMem` exposes `&self` read methods only. It hands out no `&mut File` and no
raw file descriptor, so no caller outside `mem.rs` can upgrade the handle.
* Nothing here calls `ptrace`, sends a signal, or stops the target.
There is no code path in this crate that can write to another process. Even if
one were added by mistake, the kernel would reject the write on an `O_RDONLY`
descriptor. Keep it that way.
## Subcommands
```
futmem maps [--pid N]
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
[--grep SUBSTR] [--max N]
futmem read <va> <len> [--pid N]
```
With no `--pid`, the target is resolved by scanning `/proc/*/comm` for exactly
`FIFA17.exe`. This matters: several processes in the Proton/umu tree carry
"fifa17" in their command line, including a convincing
`umu.exe /mnt/games/FIFA 17/_fifa17.exe` decoy, so a `pgrep -f` match is not good
enough. Only `comm` is authoritative.
Addresses may be written `0x140000000` or `140000000`; bare values are read as
hex, which is how this project writes them. Lengths accept `0x100`, `256`, `16k`,
`2m`.
## What `maps` gives you that `cat /proc/pid/maps` does not
### The relocation slide, computed for you
Every address in the project's Ghidra database is based at `0x180000000`. The
live module is somewhere else. `maps` prints the conversion directly:
```
CardsDLL_Win64_retail.dll PRESENT base 0x6ffffc140000 size 0x31d000 static 0x180000000 slide +0x6ffe7c140000
CardsDLL address conversion: live_va = static_va + 0x6ffe7c140000
```
It derives this by reading `ImageBase` from the *on-disk* PE (where the module
wanted to load) and subtracting it from the live load address. The live header
cannot be used for this, because Wine rewrites its `ImageBase` field to the
actual load address.
**Module bases move on every launch.** Never cache the slide across a restart.
### The Wine mapping gotcha, made visible
Wine keeps only a PE's 4 KiB header file-backed and copies every section into
anonymous memory. So this returns exactly one line:
```
$ grep CardsDLL /proc/4048/maps
6ffffc140000-6ffffc141000 r--p 00000000 00:37 2941670 /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
```
It is easy to misread that as "the module is barely mapped". A module table built
naively from path grouping reports CardsDLL as a 4 KiB module; it is really
`0x31d000` bytes. `futmem` reads `SizeOfImage` from the live PE header instead
and flags the discrepancy:
```
6ffffc140000 6ffffc45d000 3.11 MiB 1 CardsDLL_Win64_retail.dll [maps shows only 4.00 KiB; sections are anonymous]
```
This also drives address attribution. A hit inside CardsDLL's `.rdata` lands in
an anonymous region as far as the maps are concerned, so `find` checks module
image spans *before* the region list and reports
`CardsDLL_Win64_retail.dll+0x22c618` rather than `anon`.
Only genuine PE images claim a range. `/dev/nvidia0` is mapped at many scattered
addresses, and letting its min..max span count as an "image" mis-attributed
gigabytes of unrelated anonymous memory to it. Non-PE mappings own only their
exact regions.
### Honest degradation
If the game has not loaded FUT yet, the difference is visible at a glance rather
than showing as an empty table:
```
KEY MODULES
FIFA17.exe PRESENT base 0x140000000 ...
CardsDLL_Win64_retail.dll ABSENT not in this process's maps (the game has not loaded it yet)
```
An explicit `--pid` that does not point at the game is called out too, so a
wrong-target mistake cannot pass unnoticed:
```
pid 26072 (comm "bash"), 39 mapped regions <-- NOT FIFA17.exe; this is not the game process
```
## Design notes
### pread, not seek + read
`FileExt::read_at` is `pread(2)`: the offset is an argument rather than a mutable
cursor on the file. A `&ProcMem` can therefore be shared across threads later
without a mutex and without one thread's seek corrupting another's read, and a
whole class of "forgot to seek" bugs disappears.
### Partial sweeps are normal, and are reported
Many regions marked readable in `/proc/<pid>/maps` are not actually readable:
guard pages, Wine's special mappings, and pages Denuvo has not faulted in all
return `EIO`. A failed read is skipped and counted, never fatal, and every sweep
prints its counts:
```
1 hits; scanned 3552 regions (3.73 GiB), skipped 0 unreadable regions, 3 holes stepped over
```
That line is there so a zero-hit result is never mistaken for proof of absence.
When `find` returns nothing it says so explicitly.
### Chunked reads and the `pattern_len - 1` overlap
The target has roughly 3 GB resident, so regions are walked in 4 MiB chunks. The
classic bug in hand-rolled scanners is that a pattern straddling a chunk boundary
is never found: the tail of chunk N holds its first bytes and the head of chunk
N+1 holds the rest, and neither buffer contains the whole thing.
Consecutive chunks therefore overlap by exactly `pattern_len - 1` bytes. That
number is neither too small nor too large. Let a chunk cover `[0, n)` and the
pattern have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`,
so the last match wholly inside the chunk starts at `s = n - P`. Advancing by
`n - (P - 1)` starts the next chunk at `n - P + 1`, so:
* nothing is missed: every straddling match starts at `s >= n - P + 1`, inside
the next chunk;
* nothing is double-reported: the overlap begins at `n - P + 1`, strictly past
`n - P`, the last index that can host a complete match in this chunk. The
windows of reportable match *starts* are disjoint even though the byte windows
overlap.
Overlapping by `P` would report every boundary-straddling match twice;
overlapping by `P - 2` would miss one alignment.
This is verified against the live process rather than merely asserted. Region
`0x144ed3000` is swept in 4 MiB chunks, so its first boundary falls at
`0x1452d3000`. A 16-byte pattern placed 8 bytes before it straddles the boundary,
and is found exactly once:
```
$ futmem read 0x1452d2ff8 16
0001452d2ff8 a9 48 01 90 90 90 90 90 90 99 51 48 8d 0d 0c 74 |.H........QH...t|
$ futmem find --hex a948019090909090909951488d0d0c74 --module fifa17
0x0001452d2ff8 FIFA17.exe+0x52d2ff8
1 hits
```
One hit, not zero and not two.
String extraction uses a different mechanism for the same reason: it sweeps with
zero overlap and carries an unfinished run across contiguous chunks, so a string
spanning a boundary is still emitted whole. UTF-16 additionally carries a
dangling low byte when a chunk ends mid-pair.
### Dependencies
`memchr` is the only dependency. Its `memmem` uses SIMD and runs roughly an order
of magnitude faster than `windows(n).position(...)` over multiple gigabytes,
which is the difference between a several-minute sweep and a few seconds.
Everything else (argument parsing for four subcommands, maps parsing, PE header
parsing, hex dumping) is a few dozen lines of `std` and does not justify pulling
in `clap`.
### Standalone workspace
`Cargo.toml` carries an empty `[workspace]` table. Without it, cargo walks up the
directory tree, finds the untracked workspace manifest at the repo root, sees that
`futmem` is not in its `members` list, and refuses to build. Opting out from this
side avoids editing that manifest.
## Performance
Measured against pid 4048 with the game sitting at the main menu, release build,
best and worst of three runs each. These are wall clock, and they are dominated
by the `pread` syscalls rather than by the search itself.
| Sweep | Scope | Wall clock |
|---|---|---|
| `strings --min 8 --grep pack` | 3.20 GiB, all anon private | 6.3 to 6.8 s |
| `find --ascii` (global) | 3.73 GiB, all readable | 5.3 to 7.0 s |
| `find --ascii --module cardsdll` | 3.11 MiB | 0.05 s |
| `maps` | n/a | 0.05 s |
Scoping with `--module` is over a hundred times cheaper and should be the default
habit when the target is known to live in CardsDLL. A global sweep costs about
six seconds, which is cheap enough to use freely but not in a tight loop.
## Worked example
```
$ futmem find --ascii 'RS4:FutSquadSave' --module cardsdll
scanning CardsDLL_Win64_retail.dll image span 0x6ffffc140000-0x6ffffc45d000 (3.11 MiB)
from /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
pattern 16 bytes, 7 candidate regions (3.11 MiB)
0x6ffffc36c618 CardsDLL_Win64_retail.dll+0x22c618
6ffffc36c618 52 53 34 3a 46 75 74 53 71 75 61 64 53 61 76 65 |RS4:FutSquadSave|
6ffffc36c628 53 65 72 76 65 72 52 65 73 70 6f 6e 73 65 00 00 |ServerResponse..|
6ffffc36c638 5b 00 00 00 2c 25 64 00 5d 00 00 00 00 00 00 00 |[...,%d.].......|
6ffffc36c648 63 61 70 74 61 69 6e 00 22 05 93 19 01 00 00 00 |captain.".......|
1 hits; scanned 7 regions (3.11 MiB), skipped 0 unreadable regions, 0 holes stepped over
```
The `+0x22c618` offset converts straight back to the Ghidra address
`0x18022c618`. Note that the literal is `RS4:FutSquadSaveServerResponse`, not
`RS4:FutSquadSave` with a trailing NUL; read such patterns from the PE rather
than assuming them.
## Scope
This tool is client-side instrumentation. It establishes nothing about the UTAS
wire protocol and nothing a server emulator must reimplement. Its value is as the
addressing base that lets other work read server-authoritative logic out of
CardsDLL. Do not let addresses produced by this tool leak into a protocol
document as if they were protocol.
+125
View File
@@ -0,0 +1,125 @@
//! A deliberately tiny argument parser.
//!
//! Four subcommands do not justify a `clap` dependency and its build time. The
//! only subtlety is that some long options take a value (`--pid 165925`) and
//! some are bare booleans (`--utf16`). A parser cannot tell those apart from
//! the token stream alone, so each subcommand declares which of its options
//! take a value and we look the name up in that list.
use std::collections::HashMap;
pub struct Args {
opts: HashMap<String, Option<String>>,
pub positional: Vec<String>,
}
#[derive(Debug)]
pub struct ArgError(pub String);
impl std::fmt::Display for ArgError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Args {
/// `value_flags` lists the long option names that consume the following
/// token as their value. Everything else beginning with `--` is a boolean.
/// `--name=value` is always accepted regardless of the list.
pub fn parse<I: Iterator<Item = String>>(
argv: I,
value_flags: &[&str],
) -> Result<Args, ArgError> {
let mut opts: HashMap<String, Option<String>> = HashMap::new();
let mut positional = Vec::new();
let mut it = argv.peekable();
while let Some(tok) = it.next() {
if let Some(rest) = tok.strip_prefix("--") {
if rest.is_empty() {
// A bare `--` ends option parsing; the rest is positional.
positional.extend(it.by_ref());
break;
}
if let Some((name, value)) = rest.split_once('=') {
opts.insert(name.to_string(), Some(value.to_string()));
} else if value_flags.contains(&rest) {
let value = it
.next()
.ok_or_else(|| ArgError(format!("--{rest} needs a value")))?;
opts.insert(rest.to_string(), Some(value));
} else {
opts.insert(rest.to_string(), None);
}
} else {
positional.push(tok);
}
}
Ok(Args { opts, positional })
}
pub fn has(&self, name: &str) -> bool {
self.opts.contains_key(name)
}
pub fn value(&self, name: &str) -> Option<&str> {
self.opts.get(name).and_then(|v| v.as_deref())
}
pub fn parse_value<T: std::str::FromStr>(&self, name: &str) -> Result<Option<T>, ArgError> {
match self.value(name) {
None => Ok(None),
Some(raw) => raw
.parse::<T>()
.map(Some)
.map_err(|_| ArgError(format!("could not parse --{name} value {raw:?}"))),
}
}
/// Reject typos instead of silently ignoring them. `futmem find --acii foo`
/// should not quietly scan for nothing.
pub fn reject_unknown(&self, known: &[&str]) -> Result<(), ArgError> {
for name in self.opts.keys() {
if !known.contains(&name.as_str()) {
return Err(ArgError(format!("unknown option --{name}")));
}
}
Ok(())
}
}
/// Parse `0x1234`, `1234` (hex assumed when the `0x` prefix is present,
/// decimal otherwise) into a virtual address.
pub fn parse_addr(raw: &str) -> Result<u64, ArgError> {
let cleaned = raw.replace('_', "");
let parsed = match cleaned
.strip_prefix("0x")
.or_else(|| cleaned.strip_prefix("0X"))
{
Some(hex) => u64::from_str_radix(hex, 16),
// Bare addresses in this project are always written in hex
// (`6ffffc140000`), so try hex first and fall back to decimal only for
// values that are unambiguous.
None => u64::from_str_radix(&cleaned, 16).or_else(|_| cleaned.parse::<u64>()),
};
parsed.map_err(|_| ArgError(format!("bad address {raw:?}")))
}
/// Parse a length: `4096`, `0x1000`, `16k`, `2m`.
pub fn parse_len(raw: &str) -> Result<u64, ArgError> {
let lower = raw.to_ascii_lowercase();
let (body, mult) = match lower.strip_suffix('k') {
Some(b) => (b, 1024u64),
None => match lower.strip_suffix('m') {
Some(b) => (b, 1024 * 1024),
None => (lower.as_str(), 1),
},
};
let n = match body.strip_prefix("0x") {
Some(hex) => u64::from_str_radix(hex, 16),
None => body.parse::<u64>(),
}
.map_err(|_| ArgError(format!("bad length {raw:?}")))?;
Ok(n * mult)
}
+33
View File
@@ -0,0 +1,33 @@
//! Hex + ASCII rendering, shared by `read` and by `find`'s context blocks.
use std::fmt::Write as _;
use std::io::{self, Write};
fn ascii_gutter(row: &[u8]) -> String {
row.iter()
.map(|&b| {
if (0x20..=0x7e).contains(&b) {
b as char
} else {
'.'
}
})
.collect()
}
/// Classic 16-bytes-per-line dump with absolute addresses in the left column.
pub fn hexdump(out: &mut impl Write, base: u64, data: &[u8], indent: &str) -> io::Result<()> {
for (i, row) in data.chunks(16).enumerate() {
let addr = base + (i * 16) as u64;
let mut hex = String::with_capacity(50);
for (j, b) in row.iter().enumerate() {
if j == 8 {
hex.push(' ');
}
// Writing into a String is infallible.
let _ = write!(hex, "{b:02x} ");
}
writeln!(out, "{indent}{addr:012x} {hex:<50}|{}|", ascii_gutter(row))?;
}
Ok(())
}
+201
View File
@@ -0,0 +1,201 @@
//! Turning `/proc/<pid>/maps` lines into a usable module table, and turning an
//! address back into `module+offset`.
//!
//! # The Wine mapping gotcha this module exists to work around
//!
//! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every
//! section into ANONYMOUS memory. So `grep CardsDLL /proc/<pid>/maps` returns
//! exactly one line, 4 KiB long, and a module table built naively from path
//! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes.
//! An agent who trusts the maps extent concludes the module is "barely mapped"
//! and gives up, or computes a wrong module size and mis-attributes every hit.
//!
//! The fix: read `SizeOfImage` out of the live PE header at the module base.
//! That field is authoritative for the module's real extent, and the header is
//! the one part of the image that is reliably readable.
//!
//! # Deriving the slide automatically
//!
//! Wine rewrites the `ImageBase` field of the *live* header to the actual load
//! address, so the live header cannot tell us where the module wanted to load.
//! The on-disk file still can, and the maps line gives us its path. Reading the
//! on-disk `ImageBase` and subtracting gives the relocation slide:
//!
//! ```text
//! slide = live_base - disk_image_base
//! live_va = static_va + slide
//! ```
//!
//! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the
//! number every Ghidra-derived address in this project has to be adjusted by.
//! Printing it removes the most error-prone manual step in the workflow.
use crate::maps::Region;
use crate::mem::ProcMem;
use std::fs;
#[derive(Debug, Clone)]
pub struct Module {
/// Bare file name, e.g. `CardsDLL_Win64_retail.dll`.
pub name: String,
pub path: String,
/// Lowest mapped address carrying this path. For a PE this is the header.
pub base: u64,
/// Highest address still carrying this path in the maps. Badly understates
/// the truth under Wine; see the module docs.
pub maps_end: u64,
/// Number of separate maps lines mentioning this path.
pub region_count: usize,
/// `SizeOfImage` from the live PE header, the real extent.
pub size_of_image: Option<u64>,
/// `ImageBase` from the on-disk file: where the module was linked to load.
pub disk_image_base: Option<u64>,
}
impl Module {
/// Best available end address: PE-derived when we have it, maps otherwise.
pub fn end(&self) -> u64 {
match self.size_of_image {
Some(size) => self.base + size,
None => self.maps_end,
}
}
/// The relocation slide: add this to a static (Ghidra) VA to get a live VA.
pub fn slide(&self) -> Option<i128> {
self.disk_image_base
.map(|disk| self.base as i128 - disk as i128)
}
/// Is this actually a PE image, as opposed to a device node, font or `.nls`
/// data file that merely happens to be mapped?
pub fn is_pe(&self) -> bool {
self.size_of_image.is_some()
}
/// Only PE images claim an address range.
///
/// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is
/// mapped at many scattered addresses, so its min..max span covers gigabytes
/// of unrelated anonymous memory, and every hit in there would be reported
/// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions
/// listed for it in the maps, which `describe` handles as a fallback.
pub fn contains(&self, va: u64) -> bool {
self.is_pe() && va >= self.base && va < self.end()
}
}
/// Little-endian scalar helpers. Returning `Option` keeps a truncated or
/// malformed header from panicking the whole run.
fn u16_at(buf: &[u8], off: usize) -> Option<u16> {
buf.get(off..off + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
}
fn u32_at(buf: &[u8], off: usize) -> Option<u32> {
buf.get(off..off + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn u64_at(buf: &[u8], off: usize) -> Option<u64> {
buf.get(off..off + 8)
.map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
}
/// `SizeOfImage` and `ImageBase` from a PE header blob.
///
/// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte
/// COFF header follows; the optional header starts at signature+24. Within the
/// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the
/// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18
/// for PE32+ and 4 bytes at 0x1c for PE32.
fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> {
if buf.get(0..2)? != b"MZ" {
return None;
}
let nt = u32_at(buf, 0x3c)? as usize;
if buf.get(nt..nt + 4)? != b"PE\0\0" {
return None;
}
let opt = nt + 24;
let magic = u16_at(buf, opt)?;
let size_of_image = u32_at(buf, opt + 0x38)? as u64;
let image_base = match magic {
0x20b => u64_at(buf, opt + 0x18)?, // PE32+
0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32
_ => return None,
};
Some((size_of_image, image_base))
}
fn pe_from_disk(path: &str) -> Option<(u64, u64)> {
// 4 KiB is more than enough for MZ + PE + optional header on any real image.
let data = fs::read(path).ok()?;
parse_pe(&data[..data.len().min(4096)])
}
/// Build the module table. Modules are returned sorted by base address.
pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec<Module> {
use std::collections::HashMap;
let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new();
for r in regions {
let Some(path) = r.path.as_deref() else {
continue;
};
if r.pseudo() {
continue;
}
let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0));
entry.0 = entry.0.min(r.start);
entry.1 = entry.1.max(r.end);
entry.2 += 1;
}
let mut out: Vec<Module> = by_path
.into_iter()
.map(|(path, (base, maps_end, region_count))| {
// The live header gives the true extent; the on-disk header gives
// the link-time base, which is what the slide is measured against.
let live = mem.read_partial(base, 4096);
let live_pe = parse_pe(&live);
let disk_pe = pe_from_disk(path);
Module {
name: path.rsplit('/').next().unwrap_or(path).to_string(),
path: path.to_string(),
base,
maps_end,
region_count,
size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)),
disk_image_base: disk_pe.map(|(_, b)| b),
}
})
.collect();
out.sort_by_key(|m| m.base);
out
}
/// Case-insensitive lookup by name substring, e.g. `cardsdll`.
pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> {
let needle = needle.to_ascii_lowercase();
mods.iter()
.find(|m| m.name.to_ascii_lowercase().contains(&needle))
}
/// Describe an address as `module+0xoff`, falling back to the region kind.
///
/// Checking module image spans BEFORE the region list is essential here: a hit
/// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps
/// are concerned, and would otherwise be reported as `anon`, throwing away the
/// single most useful piece of context.
pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String {
if let Some(m) = mods.iter().find(|m| m.contains(va)) {
return format!("{}+{:#x}", m.name, va - m.base);
}
match regions.iter().find(|r| va >= r.start && va < r.end) {
Some(r) => match r.path.as_deref() {
Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start),
None => format!("anon:{:#x}({})", r.start, r.perms),
},
None => "unmapped".to_string(),
}
}
+576
View File
@@ -0,0 +1,576 @@
//! # futmem: a read-only live-memory inspector for FIFA 17
//!
//! Preservation and interoperability tooling for the OpenFUT project. FIFA 17's
//! `FIFA17.exe` is Denuvo-packed, so its `.text` and `.rdata` exist in plaintext
//! only inside the running process. Anything the packed executable owns can be
//! reached only through live memory. This tool is how you reach it.
//!
//! ## READ ONLY BY CONSTRUCTION
//!
//! A live game session may be running while this tool is used, and corrupting it
//! costs the user their session. The read-only property is therefore structural
//! rather than a matter of discipline:
//!
//! * `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The string
//! `OpenOptions` does not appear anywhere in this crate.
//! * `ProcMem` exposes `&self` read methods only, hands out no `&mut File` and
//! no raw descriptor, so no caller can upgrade the handle to a writable one.
//! * Nothing here calls `ptrace`, sends a signal, or stops the target.
//!
//! There is no code path in this crate that can write to another process. Even
//! if one were added by mistake, the kernel would reject the write on an
//! `O_RDONLY` descriptor.
//!
//! ## Design notes
//!
//! * **pread, not seek+read.** `FileExt::read_at` takes the offset as an
//! argument instead of mutating a shared file cursor, so a `&ProcMem` can be
//! shared across threads later without a mutex, and a whole class of "forgot
//! to seek" bugs disappears. See `mem.rs`.
//! * **Partial sweeps are normal.** Many regions marked readable in
//! `/proc/<pid>/maps` are not actually readable: guard pages, Wine's special
//! mappings, and pages Denuvo has not faulted in all return `EIO`. A failed
//! read is skipped and counted, never fatal, and the counts are printed so a
//! zero-hit result is never mistaken for proof of absence. See `scan.rs`.
//! * **Chunked reads with a `pattern_len - 1` overlap.** The target has roughly
//! 3 GB resident, so regions are walked in 4 MiB chunks. Consecutive chunks
//! overlap by exactly `pattern_len - 1` bytes so a pattern straddling a
//! boundary is still found, and not double-reported. `scan.rs` carries the
//! proof that this specific overlap is the correct one; it is the classic
//! off-by-one in scanners of this kind.
//! * **Minimal dependencies.** `memchr` is the only one, and it earns its place
//! on a multi-gigabyte sweep. Four subcommands do not justify `clap`.
//!
//! ## The Wine mapping gotcha
//!
//! Wine keeps only a PE's 4 KiB header file-backed and copies the sections into
//! anonymous memory. `grep CardsDLL /proc/<pid>/maps` therefore returns exactly
//! one 4 KiB line. A module table built naively from the maps reports CardsDLL as
//! a 4 KiB module when it is really 0x31d000 bytes. `futmem maps` reads
//! `SizeOfImage` from the live PE header instead, and derives the relocation
//! slide by comparing the live load address against the on-disk `ImageBase`, so
//! the number needed to convert Ghidra addresses to live ones is printed rather
//! than recomputed by hand.
mod cli;
mod dump;
mod image;
mod maps;
mod mem;
mod scan;
use cli::{parse_addr, parse_len, ArgError, Args};
use maps::{human, Region};
use mem::ProcMem;
use std::io::{self, BufWriter, Write};
use std::process::ExitCode;
const COMM: &str = "FIFA17.exe";
/// Modules this project always wants to know the status of.
const KEY_MODULES: [&str; 3] = [
"FIFA17.exe",
"CardsDLL_Win64_retail.dll",
"powdll_Win64_retail.dll",
];
const USAGE: &str = "\
futmem: read-only live-memory inspector for FIFA 17 (OpenFUT preservation tooling)
USAGE
futmem maps [--pid N]
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
[--grep SUBSTR] [--max N]
futmem read <va> <len> [--pid N]
COMMON
--pid N Target pid. Omitted, futmem resolves the process whose
/proc/<pid>/comm is exactly \"FIFA17.exe\". Decoy processes in the
Proton tree match a pgrep -f on \"fifa17\", so comm is the authority.
find
--ascii Pattern is ASCII text. This is the default.
--utf16 Widen the ASCII pattern to UTF-16LE, how Windows stores most UI
strings.
--hex Pattern is a hex byte string, e.g. 4883ec284885c9. Spaces ignored.
--module NAME Restrict the scan to a module's image span, matched case
insensitively on a substring of the file name, e.g. --module cardsdll.
--max N Stop after N hits.
strings
--min N Minimum run length. Default 6.
--range A-B Scan exactly this address range, e.g. --range 0x1450f3000-0x14b1a3000.
--module NAME Scan a module's image span.
--utf16 Extract UTF-16LE strings instead of ASCII.
--grep S Only print strings containing S, matched case insensitively.
--max N Stop after N strings.
With none of --range or --module, the default scope is every anonymous private
region, which is where a packed executable's decrypted data lives.
Addresses may be written 0x140000000 or 140000000; bare values are read as hex.
Lengths accept 0x100, 256, 16k, 2m.
All operations are strictly read-only. See the crate docs for the guarantee.
";
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
let Some(sub) = argv.first().cloned() else {
print!("{USAGE}");
return ExitCode::FAILURE;
};
let rest = argv.into_iter().skip(1);
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
let result = match sub.as_str() {
"maps" => cmd_maps(&mut out, rest),
"find" => cmd_find(&mut out, rest),
"strings" => cmd_strings(&mut out, rest),
"read" => cmd_read(&mut out, rest),
"-h" | "--help" | "help" => {
print!("{USAGE}");
return ExitCode::SUCCESS;
}
other => {
eprintln!("futmem: unknown subcommand {other:?}\n");
eprint!("{USAGE}");
return ExitCode::FAILURE;
}
};
// Flushing separately so a broken pipe (futmem strings | head) is not
// reported as a failure.
let flushed = out.flush();
match (result, flushed) {
(Err(e), _) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
(_, Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
(Err(e), _) => {
eprintln!("futmem: {e}");
ExitCode::FAILURE
}
(Ok(()), Err(e)) => {
eprintln!("futmem: {e}");
ExitCode::FAILURE
}
(Ok(()), Ok(())) => ExitCode::SUCCESS,
}
}
fn arg_err(e: ArgError) -> io::Error {
new_invalid(e)
}
/// Resolve the target pid from `--pid` or by scanning `/proc/*/comm`.
fn resolve_pid(args: &Args) -> io::Result<i32> {
match args.parse_value::<i32>("pid").map_err(arg_err)? {
Some(pid) => Ok(pid),
None => maps::find_pid(COMM),
}
}
// ---------------------------------------------------------------- maps
fn cmd_maps<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
args.reject_unknown(&["pid"]).map_err(arg_err)?;
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
// Report the comm we actually found, not the one we hoped for: an explicit
// --pid may point anywhere, and silently labelling it "FIFA17.exe" would
// make a wrong-target mistake invisible.
let comm = maps::read_comm(pid);
let warn = if comm == COMM {
String::new()
} else {
format!(" <-- NOT {COMM}; this is not the game process")
};
writeln!(
out,
"pid {pid} (comm {comm:?}), {} mapped regions{warn}",
regions.len()
)?;
writeln!(out)?;
// -- key modules first, so "is FUT loaded yet?" is answerable at a glance.
writeln!(out, "KEY MODULES")?;
for want in KEY_MODULES {
match image::find_module(&mods, want) {
Some(m) => {
let slide = match m.slide() {
Some(s) if s >= 0 => format!("slide +{:#x}", s),
Some(s) => format!("slide -{:#x}", -s),
None => "slide unknown".to_string(),
};
let static_base = m
.disk_image_base
.map(|b| format!("static {b:#x}"))
.unwrap_or_else(|| "static ?".to_string());
writeln!(
out,
" {:<28} PRESENT base {:#x} size {:#x} {static_base} {slide}",
m.name,
m.base,
m.size_of_image.unwrap_or(m.maps_end - m.base),
)?;
}
None => writeln!(
out,
" {want:<28} ABSENT not in this process's maps (the game has not loaded it yet)"
)?,
}
}
if let Some(m) = image::find_module(&mods, "CardsDLL") {
if let Some(slide) = m.slide() {
writeln!(out)?;
writeln!(
out,
" CardsDLL address conversion: live_va = static_va + {slide:#x}"
)?;
writeln!(
out,
" (Ghidra static base {:#x} -> live base {:#x}. Valid for pid {pid} only; \
module bases move on every launch.)",
m.disk_image_base.unwrap_or(0),
m.base
)?;
}
}
writeln!(out)?;
// -- full module table
writeln!(out, "MODULES (file-backed, grouped by path)")?;
writeln!(
out,
" {:<14} {:<14} {:<12} {:>5} name",
"base", "end (PE)", "size", "regs"
)?;
for m in &mods {
let note = if !m.is_pe() {
// A device node, .nls table or font, not a loadable image. Its
// min..max span is meaningless, so say so rather than imply an extent.
" [non-PE mapping; span is min..max of scattered regions]".to_string()
} else if m.maps_end - m.base < m.end() - m.base {
// The Wine gotcha, made visible instead of silently misleading.
format!(
" [maps shows only {}; sections are anonymous]",
human(m.maps_end - m.base)
)
} else {
String::new()
};
writeln!(
out,
" {:<14x} {:<14x} {:<12} {:>5} {}{}",
m.base,
m.end(),
human(m.end() - m.base),
m.region_count,
m.name,
note
)?;
}
writeln!(out)?;
// -- writable + executable regions: where packers put decrypted code.
let wx: Vec<&Region> = regions
.iter()
.filter(|r| r.writable() && r.executable())
.collect();
let wx_total: u64 = wx.iter().map(|r| r.size()).sum();
writeln!(
out,
"WRITABLE + EXECUTABLE REGIONS ({} regions, {})",
wx.len(),
human(wx_total)
)?;
// Wine emits hundreds of 4 KiB per-thread stubs that are pure noise.
let mut small_wx = 0usize;
for r in &wx {
if r.size() <= 64 * 1024 {
small_wx += 1;
continue;
}
writeln!(
out,
" {:012x}-{:012x} {} {:>10} {}",
r.start,
r.end,
r.perms,
human(r.size()),
describe_region(r, &mods)
)?;
}
if small_wx > 0 {
writeln!(
out,
" (+{small_wx} regions of 64 KiB or less, Wine per-thread stubs, omitted)"
)?;
}
writeln!(out)?;
// -- large anonymous private regions
let mut anon: Vec<&Region> = regions
.iter()
.filter(|r| r.anonymous() && r.private() && r.readable() && r.size() > 1024 * 1024)
.collect();
anon.sort_by_key(|r| std::cmp::Reverse(r.size()));
let anon_total: u64 = anon.iter().map(|r| r.size()).sum();
writeln!(
out,
"ANONYMOUS PRIVATE REGIONS OVER 1 MB ({} regions, {})",
anon.len(),
human(anon_total)
)?;
for r in &anon {
writeln!(
out,
" {:012x}-{:012x} {} {:>10} {}",
r.start,
r.end,
r.perms,
human(r.size()),
describe_region(r, &mods)
)?;
}
Ok(())
}
/// Label a region with the module whose image span contains it, if any.
fn describe_region(r: &Region, mods: &[image::Module]) -> String {
if let Some(p) = r.path.as_deref() {
// The file offset matters for a packed executable: it says which part of
// the on-disk image this mapping still corresponds to.
let name = p.rsplit('/').next().unwrap_or(p);
return format!("{name} @fileoff {:#x}", r.offset);
}
match mods.iter().find(|m| m.contains(r.start)) {
Some(m) => format!("anon, inside {} image", m.name),
None => "anon".to_string(),
}
}
// ---------------------------------------------------------------- find
fn cmd_find<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let known = ["pid", "ascii", "utf16", "hex", "module", "max"];
let args = Args::parse(argv, &["pid", "module", "max"]).map_err(arg_err)?;
args.reject_unknown(&known).map_err(arg_err)?;
let Some(raw) = args.positional.first() else {
return Err(new_invalid(ArgError("find needs a pattern".into())));
};
let pattern: Vec<u8> = if args.has("hex") {
parse_hex(raw).map_err(arg_err)?
} else if args.has("utf16") {
// Widen ASCII to UTF-16LE: each byte followed by a zero high byte.
raw.bytes().flat_map(|b| [b, 0]).collect()
} else {
raw.as_bytes().to_vec()
};
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
let module = match args.value("module") {
Some(name) => match image::find_module(&mods, name) {
Some(m) => Some(m.clone()),
None => {
return Err(new_invalid(ArgError(format!(
"no module matching {name:?} in pid {pid}; run `futmem maps` to list them"
))))
}
},
None => None,
};
if let Some(m) = &module {
writeln!(
out,
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
m.name,
m.base,
m.end(),
human(m.end() - m.base),
m.path
)?;
}
let targets = scan::scan_targets(&regions, module.as_ref(), false);
let target_bytes: u64 = targets.iter().map(|r| r.size()).sum();
writeln!(
out,
"pattern {} bytes, {} candidate regions ({})",
pattern.len(),
targets.len(),
human(target_bytes)
)?;
writeln!(out)?;
let mut hits: Vec<u64> = Vec::new();
let stats = scan::find_pattern(&mem, &targets, &pattern, max, |va| hits.push(va));
for va in &hits {
let loc = image::describe(*va, &mods, &regions);
writeln!(out, "{va:#014x} {loc}")?;
let ctx = mem.read_partial(*va, 64);
if !ctx.is_empty() {
dump::hexdump(out, *va, &ctx, " ")?;
}
}
writeln!(out)?;
writeln!(out, "{} hits; {}", hits.len(), stats.summary())?;
if hits.is_empty() {
writeln!(
out,
"note: {} regions were unreadable, so an empty result is NOT proof of absence.",
stats.regions_skipped
)?;
}
Ok(())
}
fn parse_hex(raw: &str) -> Result<Vec<u8>, ArgError> {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_whitespace() && *c != ':' && *c != ',')
.collect();
let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
if !cleaned.len().is_multiple_of(2) {
return Err(ArgError(format!(
"hex pattern has an odd number of digits ({})",
cleaned.len()
)));
}
(0..cleaned.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&cleaned[i..i + 2], 16)
.map_err(|_| ArgError(format!("bad hex byte {:?}", &cleaned[i..i + 2])))
})
.collect()
}
// ---------------------------------------------------------------- strings
fn cmd_strings<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let known = ["pid", "min", "range", "module", "utf16", "grep", "max"];
let args =
Args::parse(argv, &["pid", "min", "range", "module", "grep", "max"]).map_err(arg_err)?;
args.reject_unknown(&known).map_err(arg_err)?;
let min = args
.parse_value::<usize>("min")
.map_err(arg_err)?
.unwrap_or(6);
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
let grep = args.value("grep");
let utf16 = args.has("utf16");
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
let targets: Vec<Region> = if let Some(range) = args.value("range") {
let (a, b) = range
.split_once('-')
.ok_or_else(|| new_invalid(ArgError("--range wants START-END".into())))?;
let start = parse_addr(a).map_err(arg_err)?;
let end = parse_addr(b).map_err(arg_err)?;
if end <= start {
return Err(new_invalid(ArgError(format!(
"--range end {end:#x} is not above start {start:#x}"
))));
}
writeln!(out, "scanning {start:#x}-{end:#x} ({})", human(end - start))?;
vec![Region {
start,
end,
perms: "r--p".to_string(),
offset: 0,
path: None,
}]
} else if let Some(name) = args.value("module") {
let m = image::find_module(&mods, name).ok_or_else(|| {
new_invalid(ArgError(format!(
"no module matching {name:?} in pid {pid}"
)))
})?;
writeln!(
out,
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
m.name,
m.base,
m.end(),
human(m.end() - m.base),
m.path
)?;
scan::scan_targets(&regions, Some(m), false)
} else {
// Default scope: anonymous private memory, where a packed executable's
// decrypted data lives.
let t = scan::scan_targets(&regions, None, true);
let bytes: u64 = t.iter().map(|r| r.size()).sum();
writeln!(
out,
"scanning {} anonymous private regions ({})",
t.len(),
human(bytes)
)?;
t
};
let mut count = 0usize;
let stats = scan::find_strings(&mem, &targets, utf16, min, grep, max, |va, s| {
count += 1;
// Ignoring the write error here keeps the closure simple; a broken pipe
// is caught when the buffer is flushed in main.
let _ = writeln!(out, "{va:#014x} {}", s);
});
writeln!(out)?;
writeln!(out, "{count} strings; {}", stats.summary())?;
Ok(())
}
// ---------------------------------------------------------------- read
fn cmd_read<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
args.reject_unknown(&["pid"]).map_err(arg_err)?;
if args.positional.len() < 2 {
return Err(new_invalid(ArgError("read needs <va> and <len>".into())));
}
let va = parse_addr(&args.positional[0]).map_err(arg_err)?;
let len = parse_len(&args.positional[1]).map_err(arg_err)?;
if len == 0 || len > 64 * 1024 * 1024 {
return Err(new_invalid(ArgError(format!(
"length {len} out of range (1 .. 64 MiB)"
))));
}
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
writeln!(out, "{va:#x} {}", image::describe(va, &mods, &regions))?;
let data = mem.read_exact(va, len as usize)?;
dump::hexdump(out, va, &data, "")?;
Ok(())
}
fn new_invalid(e: ArgError) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, e.0)
}
+168
View File
@@ -0,0 +1,168 @@
//! Parsing `/proc/<pid>/maps` and finding the FIFA 17 process.
use std::fs;
use std::io;
#[derive(Debug, Clone)]
pub struct Region {
pub start: u64,
pub end: u64,
/// The raw four permission characters, e.g. `rwxp` or `r--s`.
pub perms: String,
/// File offset this mapping starts at, meaningless for anonymous regions.
pub offset: u64,
/// `None` for anonymous mappings.
pub path: Option<String>,
}
impl Region {
pub fn size(&self) -> u64 {
self.end - self.start
}
pub fn readable(&self) -> bool {
self.perms.as_bytes().first() == Some(&b'r')
}
pub fn writable(&self) -> bool {
self.perms.as_bytes().get(1) == Some(&b'w')
}
pub fn executable(&self) -> bool {
self.perms.as_bytes().get(2) == Some(&b'x')
}
pub fn private(&self) -> bool {
self.perms.as_bytes().get(3) == Some(&b'p')
}
pub fn anonymous(&self) -> bool {
self.path.is_none()
}
/// Pseudo-files the kernel exposes. Reading `[vvar]` through
/// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here.
pub fn pseudo(&self) -> bool {
matches!(self.path.as_deref(), Some(p) if p.starts_with('['))
}
}
pub fn read_maps(pid: i32) -> io::Result<Vec<Region>> {
let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| {
let hint = if fs::metadata(format!("/proc/{pid}")).is_err() {
format!("no process with pid {pid}")
} else {
format!("pid {pid} exists but its maps are unreadable (different user?)")
};
io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}"))
})?;
Ok(text.lines().filter_map(parse_line).collect())
}
/// The target's `comm`, so output can name what was actually inspected rather
/// than assuming an explicit `--pid` pointed at the game.
pub fn read_comm(pid: i32) -> String {
fs::read_to_string(format!("/proc/{pid}/comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".to_string())
}
/// Pull the next whitespace-delimited field starting at `cursor`, advancing it.
fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> {
let bytes = line.as_bytes();
while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
*cursor += 1;
}
let start = *cursor;
while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
*cursor += 1;
}
if start == *cursor {
None
} else {
Some(&line[start..*cursor])
}
}
fn parse_line(line: &str) -> Option<Region> {
// Format: `start-end perms offset dev inode path`
//
// The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may
// carry a ` (deleted)` suffix, so we consume exactly five leading fields by
// position and take the untouched remainder as the path.
//
// Doing this with `line.find(inode)` to locate the split point is a trap:
// the inode of an anonymous mapping is "0", and `find("0")` happily matches
// a zero digit inside the address range at the very start of the line. That
// silently turns half the address into a path. Hence the explicit cursor.
let mut cursor = 0usize;
let range = next_field(line, &mut cursor)?;
let perms = next_field(line, &mut cursor)?;
let offset = next_field(line, &mut cursor)?;
let _dev = next_field(line, &mut cursor)?;
let _inode = next_field(line, &mut cursor)?;
let (start, end) = range.split_once('-')?;
let start = u64::from_str_radix(start, 16).ok()?;
let end = u64::from_str_radix(end, 16).ok()?;
let tail = line[cursor..].trim();
let path = if tail.is_empty() {
None
} else {
Some(tail.to_string())
};
Some(Region {
start,
end,
perms: perms.to_string(),
offset: u64::from_str_radix(offset, 16).ok()?,
path,
})
}
/// Find the FIFA 17 process.
///
/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen
/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe)
/// whose command lines mention fifa17, and at least one of them
/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the
/// real game has `comm == "FIFA17.exe"`. Its `/proc/<pid>/exe` points at
/// wine64-preloader, which is expected and is not a reason to doubt the match.
pub fn find_pid(comm_name: &str) -> io::Result<i32> {
let mut hits = Vec::new();
for entry in fs::read_dir("/proc")? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
if comm.trim() == comm_name {
hits.push(pid);
}
}
}
match hits.len() {
0 => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"),
)),
1 => Ok(hits[0]),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()),
)),
}
}
pub fn human(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Read-only access to another process's address space.
//!
//! # The safety property this module exists to guarantee
//!
//! A live FIFA 17 session may be running while this tool is used. Corrupting it
//! costs the user their progress and their patience. So the guarantee here is
//! structural, not a matter of being careful:
//!
//! * `/proc/<pid>/mem` is opened with [`File::open`], which is `O_RDONLY`.
//! There is no [`std::fs::OpenOptions`] anywhere in this crate.
//! * [`ProcMem`] exposes `&self` read methods only. It hands out no `&mut File`
//! and no raw fd, so no caller outside this module can upgrade the handle.
//! * Nothing in the crate calls `ptrace`, sends a signal, or writes to any
//! path under `/proc`.
//!
//! Even if a caller tried to write, the kernel would reject it on an `O_RDONLY`
//! descriptor. The type system and the open mode agree, which is the point.
//!
//! # Why pread and not seek + read
//!
//! [`FileExt::read_at`] is `pread(2)`: it takes the offset as an argument
//! instead of mutating a shared file cursor. That means a `&ProcMem` can be
//! shared across threads later without a mutex and without one thread's seek
//! corrupting another's read. It also removes a whole class of "forgot to seek"
//! bugs. There is never a reason to prefer seek+read here.
use std::fs::File;
use std::io;
use std::os::unix::fs::FileExt;
/// The page size we assume when stepping over an unreadable hole. Every x86-64
/// mapping is a multiple of this, so it is a safe granularity for recovery.
pub const PAGE: u64 = 4096;
/// A read-only handle on a process's memory.
pub struct ProcMem {
file: File,
}
/// What a single chunk read produced.
pub enum ChunkRead {
/// `n` bytes landed in the buffer. May be shorter than requested when the
/// read ran into an unmapped hole partway through.
Got(usize),
/// Nothing readable at this address at all.
Hole,
}
impl ProcMem {
/// Open the target read-only. See the module docs for why this is
/// `File::open` and must stay that way.
pub fn open(pid: i32) -> io::Result<Self> {
let file = File::open(format!("/proc/{pid}/mem")).map_err(|e| {
io::Error::new(
e.kind(),
format!("opening /proc/{pid}/mem: {e} (same-user or CAP_SYS_PTRACE required)"),
)
})?;
Ok(Self { file })
}
/// Best-effort read. Never fatal: a hole reports [`ChunkRead::Hole`] rather
/// than propagating an error, because in a 3 GB sweep unreadable regions are
/// the normal case, not an exceptional one.
///
/// Guard pages, Wine's special mappings and pages Denuvo has not faulted in
/// are all marked readable in `/proc/<pid>/maps` yet return `EIO` here. The
/// caller counts these and reports the total so the user knows the sweep was
/// partial.
pub fn read_chunk(&self, va: u64, buf: &mut [u8]) -> ChunkRead {
match self.file.read_at(buf, va) {
Ok(0) | Err(_) => ChunkRead::Hole,
Ok(n) => ChunkRead::Got(n),
}
}
/// Strict read for cases where a short read is genuinely an error, such as
/// an explicit `futmem read <va> <len>` the user asked for by hand.
pub fn read_exact(&self, va: u64, len: usize) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; len];
self.file.read_exact_at(&mut buf, va).map_err(|e| {
io::Error::new(
e.kind(),
format!("reading {len} bytes at {va:#x}: {e} (address may be unmapped)"),
)
})?;
Ok(buf)
}
/// Read up to `len` bytes, returning however many were actually available.
/// Used for printing context around a hit that sits near the end of a region.
pub fn read_partial(&self, va: u64, len: usize) -> Vec<u8> {
let mut buf = vec![0u8; len];
match self.file.read_at(&mut buf, va) {
Ok(n) => {
buf.truncate(n);
buf
}
Err(_) => Vec::new(),
}
}
}
+376
View File
@@ -0,0 +1,376 @@
//! Chunked sweeping of a remote address space, plus the two things we sweep
//! for: byte patterns and printable strings.
//!
//! # Why chunking, and the off-by-one that ruins scanners
//!
//! The target has roughly 3 GB resident. Reading a region in one allocation is
//! wasteful and can fail outright, so regions are walked in 4 MiB chunks.
//!
//! The classic bug in every hand-rolled scanner is that a pattern straddling a
//! chunk boundary is never found: the tail of chunk N holds the first few bytes
//! and the head of chunk N+1 holds the rest, and neither buffer contains the
//! whole thing. The fix is to overlap consecutive chunks by `pattern_len - 1`
//! bytes.
//!
//! That specific overlap is exactly right, and it is worth showing why it is
//! neither too small nor too large. Let a chunk cover `[0, n)` and the pattern
//! have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, so
//! the last match fully inside the chunk starts at `s = n - P`. Any match
//! starting at `s > n - P` runs off the end and must be caught by the next
//! chunk, so the next chunk has to begin at or before `n - P + 1`. Advancing by
//! `n - (P - 1)` starts it at precisely `n - P + 1`:
//!
//! * Nothing is missed: every straddling match starts at `s >= n - P + 1`,
//! which is inside the next chunk.
//! * Nothing is double-reported: the first index of the overlap is
//! `n - P + 1`, which is strictly greater than `n - P`, the last index that
//! can host a complete match in this chunk. The two windows of *reportable*
//! match starts are disjoint even though the byte windows overlap.
//!
//! Overlapping by `P` instead would report every boundary-straddling match
//! twice; overlapping by `P - 2` would miss one alignment. Hence `P - 1`.
//!
//! # Holes
//!
//! A region marked readable in `/proc/<pid>/maps` is frequently not readable in
//! practice: guard pages, Wine's special mappings, and pages Denuvo has not
//! faulted in all return `EIO`. These are counted and stepped over a page at a
//! time, never propagated as errors, because in a sweep this size they are
//! routine. The counts are reported so the user knows the sweep was partial and
//! does not read a zero-hit result as proof of absence.
use crate::image::Module;
use crate::maps::Region;
use crate::mem::{ChunkRead, ProcMem, PAGE};
pub const CHUNK: usize = 4 * 1024 * 1024;
#[derive(Default, Debug)]
pub struct SweepStats {
pub regions_scanned: usize,
/// Regions from which not a single byte could be read.
pub regions_skipped: usize,
/// Individual chunk reads that hit an unreadable hole.
pub holes: usize,
pub bytes_read: u64,
}
impl SweepStats {
pub fn summary(&self) -> String {
format!(
"scanned {} regions ({}), skipped {} unreadable regions, {} holes stepped over",
self.regions_scanned,
crate::maps::human(self.bytes_read),
self.regions_skipped,
self.holes
)
}
}
fn align_up(va: u64, align: u64) -> u64 {
va.div_ceil(align) * align
}
/// Walk one region in chunks, invoking `f(chunk_va, bytes, contiguous)`.
///
/// `contiguous` is true when this chunk's data continues directly from the
/// previous callback with no gap, which string extraction needs in order to
/// join a run that spans a boundary. `overlap` is `pattern_len - 1` for pattern
/// search and 0 for stateful scanners that track continuity themselves.
///
/// Returns early (`false`) if `f` signals it has seen enough.
fn sweep_region<F>(
mem: &ProcMem,
region: &Region,
overlap: usize,
buf: &mut [u8],
stats: &mut SweepStats,
f: &mut F,
) -> bool
where
F: FnMut(u64, &[u8], bool) -> bool,
{
let mut pos = region.start;
let mut contiguous = false;
let mut read_anything = false;
while pos < region.end {
let want = (buf.len() as u64).min(region.end - pos) as usize;
match mem.read_chunk(pos, &mut buf[..want]) {
ChunkRead::Hole => {
stats.holes += 1;
contiguous = false;
// Step to the next page; the current one is unreadable.
pos = align_up(pos + 1, PAGE);
}
ChunkRead::Got(n) => {
read_anything = true;
stats.bytes_read += n as u64;
if !f(pos, &buf[..n], contiguous) {
return false;
}
if pos + n as u64 >= region.end {
break;
}
if n < want {
// Short read: an unmapped hole begins at pos + n. No pattern
// can span a hole, so no overlap is needed here; resume on
// the next page boundary.
contiguous = false;
pos = align_up(pos + n as u64 + 1, PAGE);
} else {
if n <= overlap {
break; // cannot make forward progress
}
contiguous = true;
pos += (n - overlap) as u64;
}
}
}
}
if read_anything {
stats.regions_scanned += 1;
} else {
stats.regions_skipped += 1;
}
true
}
/// Which regions a sweep should touch.
pub fn scan_targets(regions: &[Region], module: Option<&Module>, anon_only: bool) -> Vec<Region> {
regions
.iter()
.filter(|r| r.readable() && !r.pseudo())
.filter(|r| !anon_only || r.anonymous())
.filter_map(|r| match module {
None => Some(r.clone()),
// Clip the region to the module's image span rather than dropping
// it: under Wine a module's sections live in large anonymous
// regions that may extend past the image.
Some(m) => {
let start = r.start.max(m.base);
let end = r.end.min(m.end());
if start < end {
let mut clipped = (*r).clone();
clipped.start = start;
clipped.end = end;
Some(clipped)
} else {
None
}
}
})
.collect::<Vec<_>>()
}
/// Search every target region for `pattern`. Calls `hit(va)` per match.
pub fn find_pattern<F>(
mem: &ProcMem,
targets: &[Region],
pattern: &[u8],
max: Option<usize>,
mut hit: F,
) -> SweepStats
where
F: FnMut(u64),
{
let mut stats = SweepStats::default();
if pattern.is_empty() {
return stats;
}
let finder = memchr::memmem::Finder::new(pattern);
let overlap = pattern.len() - 1;
// The buffer must comfortably exceed the overlap or progress stalls.
let mut buf = vec![0u8; CHUNK.max(pattern.len() * 4)];
let mut found = 0usize;
for region in targets {
let keep_going = sweep_region(
mem,
region,
overlap,
&mut buf,
&mut stats,
&mut |base, data, _contiguous| {
for off in finder.find_iter(data) {
hit(base + off as u64);
found += 1;
if max.is_some_and(|m| found >= m) {
return false;
}
}
true
},
);
if !keep_going {
break;
}
}
stats
}
fn printable(b: u8) -> bool {
(0x20..=0x7e).contains(&b)
}
/// Extracts printable runs, carrying an unfinished run across contiguous chunks
/// so a string straddling a boundary is still emitted whole.
struct StringScanner {
utf16: bool,
min: usize,
run: Vec<u8>,
run_start: u64,
open: bool,
/// UTF-16 only: a low byte at the very end of a chunk whose high byte will
/// arrive in the next one.
carry: Option<(u64, u8)>,
}
impl StringScanner {
fn new(utf16: bool, min: usize) -> Self {
Self {
utf16,
min,
run: Vec::with_capacity(256),
run_start: 0,
open: false,
carry: None,
}
}
fn flush<F: FnMut(u64, &str)>(&mut self, emit: &mut F) {
if self.open && self.run.len() >= self.min {
// Runs are printable ASCII by construction, so this cannot fail.
if let Ok(s) = std::str::from_utf8(&self.run) {
emit(self.run_start, s);
}
}
self.run.clear();
self.open = false;
}
fn push<F: FnMut(u64, &str)>(&mut self, va: u64, b: u8, emit: &mut F) {
if !self.open {
self.open = true;
self.run_start = va;
}
self.run.push(b);
// Guard against a pathological all-printable megabyte eating memory.
if self.run.len() >= 4096 {
self.flush(emit);
}
}
fn feed<F: FnMut(u64, &str)>(
&mut self,
base: u64,
data: &[u8],
contiguous: bool,
emit: &mut F,
) {
if !contiguous {
self.flush(emit);
self.carry = None;
}
if self.utf16 {
self.feed_utf16(base, data, emit);
} else {
for (i, &b) in data.iter().enumerate() {
if printable(b) {
self.push(base + i as u64, b, emit);
} else {
self.flush(emit);
}
}
}
}
fn feed_utf16<F: FnMut(u64, &str)>(&mut self, base: u64, data: &[u8], emit: &mut F) {
let mut i = 0usize;
// A pair split across the chunk boundary: complete it if the high byte
// is the expected 0x00, otherwise the run ends here.
if let Some((addr, lo)) = self.carry.take() {
if data.first() == Some(&0) && printable(lo) {
self.push(addr, lo, emit);
i = 1;
} else {
self.flush(emit);
}
}
while i + 1 < data.len() {
let (lo, hi) = (data[i], data[i + 1]);
if hi == 0 && printable(lo) {
self.push(base + i as u64, lo, emit);
i += 2;
} else {
self.flush(emit);
i += 1;
}
}
if i < data.len() {
self.carry = Some((base + i as u64, data[i]));
}
}
}
/// Extract strings from every target region. Calls `emit(va, text)`.
pub fn find_strings<F>(
mem: &ProcMem,
targets: &[Region],
utf16: bool,
min: usize,
grep: Option<&str>,
max: Option<usize>,
mut emit: F,
) -> SweepStats
where
F: FnMut(u64, &str),
{
let mut stats = SweepStats::default();
let mut buf = vec![0u8; CHUNK];
let grep_lower = grep.map(|g| g.to_ascii_lowercase());
let mut count = 0usize;
for region in targets {
let mut scanner = StringScanner::new(utf16, min);
let mut stop = false;
// overlap 0: the scanner tracks continuity itself via `contiguous`.
let keep_going = sweep_region(
mem,
region,
0,
&mut buf,
&mut stats,
&mut |base, data, contiguous| {
scanner.feed(base, data, contiguous, &mut |va, s| {
let matches = match &grep_lower {
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
None => true,
};
if matches {
emit(va, s);
count += 1;
if max.is_some_and(|m| count >= m) {
stop = true;
}
}
});
!stop
},
);
scanner.flush(&mut |va, s| {
let matches = match &grep_lower {
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
None => true,
};
if matches {
emit(va, s);
}
});
if !keep_going || stop {
break;
}
}
stats
}
+1
View File
@@ -0,0 +1 @@
gvenv/
+46
View File
@@ -669,6 +669,52 @@ FUT_RS4_CONFIG = (
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
)]
# FUT_TRADING: the transfer-market equivalent of the store block above.
#
# WHY THIS IS HERE AND NOT IN /settings. "Place on Transfer List" and "List on
# Transfer Market" are greyed out because the TO_TRADE_PILE predicate
# FUN_1801a7260 needs a service gate at vtable+0x270, which is
# `movzx eax, byte [rcx+0x1fd2e]; ret`. That byte is the tradingEnabled gate and it
# reads 0.
#
# Sending tradingEnabled through /settings does NOT move it, PROVEN live 2026-08-06:
# the arm is right (case 0x336 writes param_2[10]) and the applier is right
# (0x1fd2e = param_2[10] == 1), but the applier has NO caller Ghidra can see and is
# not reachable from the settings deserializer. The decisive measurement: we served
# maximumTradePileSize=77 and NO int gate field carries 77 (+0x1fd14=0, +0x1fd4c=0,
# +0x1fd54=480). Every gate byte is a constructor default. That also explains
# storeEnabled reading 1: a default, never our value.
#
# REFUTED 2026-08-06, KEPT ONLY AS A RECORD. THIS DOES NOT WORK. Do not turn it on
# expecting an effect, and do not reason from it.
#
# The reasoning above was wrong in two places and the flag is inert:
#
# 1. IS_TRADING_ENABLED IS AN OUTPUT NAME, NOT AN INPUT. FUN_18006cc60 is a
# PUBLISHER: at 0x18006ccc6 it does `call [rax+0x270]` (which reads gate byte
# 0x1fd2e), then `lea rdx,[IS_TRADING_ENABLED]` and hands the value OUT under
# that name. The only rip-relative reference to the literal 0x1801fc118 in the
# whole of .text is that lea. There is no comparison against it anywhere, so a
# client-config key of that name cannot be read as an input by anything. The same
# is true of the IS_* store keys above, which means the store block may also be
# inert and its apparent success was never actually attributed.
# 2. The gate byte was briefly measured as 1 and that was over-claimed as a success.
# On a fresh session it reads 0, and a thorough re-measurement read 0 on the very
# pid where it had read 1. Either the first read was transient or something clears
# it after login. The only writer of 0x1fd2e is FUN_18011dc50 at 0x18011dc91.
#
# What IS now known, and supersedes the "/settings is dead" claim in the note above:
# FUN_18011dc50 is NOT unreachable. It is a VIRTUAL method at model vtable slot
# +0x988 (absolute pointer at 0x18021cc28), which is why a direct-call search found
# no callers. The real chain is
# settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
# -> completion callback FUN_180173e00 -> vt+0x988 / vt+0x998 -> gate bytes
# and FUN_180173e00 bails before applying anything unless the int at response+0x1c
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
#
# Default OFF and it should stay off.
+ ([(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")]
if os.environ.get("FUT_TRADING") else [])
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Offline check: every /settings flag we ship is one the client actually switches on.
A flag name is not validated by anything at runtime. The client hashes the string
we send and switches on the result, so a typo, a renamed field or a flag that
simply has no arm in the switch is INERT and looks exactly like "the fix did not
work". This asserts each shipped name against two independent sources:
1. docs/fut_atoms.tsv -- the recovered atom table (the name must hash to an id)
2. the switch arms recovered from 0x18013c6d0 (the id must have an arm)
Source 2 is the one that matters: enableSquadBuildingSetsFeature is a perfectly
real atom with NO arm, so source 1 alone would have passed it.
Run before shipping any settings change. No server needed.
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
# The 42 atoms with an arm in FUN_18013c6d0, recovered 2026-08-05 by
# tools/ghidra_queries/q_settings_flags.py + q_settings_types.py (full decompile,
# both halves of the switch, coverage asserted by char count).
SWITCH_ARMS = {
0x18: "allowGracePeriodForSquadBuildingSets",
0x19: "allowUntradeableForSquadBuildingSets",
0x6D: "cardPackStoreEnabled", 0x6E: "cardPackStoreEnabled_JP",
0x80: "checkServerDbVersion", 0x86: "clientKeepAliveResetTimeoutSec",
0x8C: "clubCreateThreshold", 0x98: "coinEnabled", 0x99: "coinEnabled_JP",
0xA3: "constrainGracePeriod", 0xBB: "couchPlayEnabled",
0xF9: "enableDraftMode", 0xFA: "enableOfflineDraftMode",
0xFB: "enableLiveMessaging", 0xFC: "enableLoyaltyBonusForConceptPlayers",
0xFD: "enableObjectives", 0xFE: "enableObjectivesAsManagerTasks",
0xFF: "enableSinglePlayerDraftMode", 0x118: "extendGameSessionTimerSec",
0x11F: "fifaPointsEnabled", 0x120: "fifaPointsEnabled_JP",
0x133: "friendlySeasonsEnabled", 0x13D: "getOperationTimeoutSec",
0x16C: "itemDbVersion", 0x1C0: "maximumTradePileSize",
0x1CD: "mtxEnabled", 0x1CE: "mtxEnabled_JP",
0x1DE: "numEndMatchRetriesAllowed", 0x20E: "packOpeningAnimationEnabled",
0x242: "pointsPackStoreEnabled", 0x257: "processingStateEnabled",
0x28A: "returningUserRewardsScreenEnabled",
0x2D0: "squadBuildingSetsGracePeriodMinutes",
0x2F1: "storeEnabled", 0x2F2: "storeEnabled_JP",
0x2F3: "storyModeRewardEnabled",
0x2F5: "championsScheduleViewPeriodInMinutes",
0x30F: "enableFloatPointSquadRating",
0x310: "enableLegacyYearInfoInItemResourceId",
0x320: "tokenRedemptionEnabled", 0x32D: "tournamentQuitEnabled",
0x336: "tradingEnabled",
}
# Arms that do NOT simply store a value. Shipping these has side effects.
SPECIAL = {
"enableObjectives": "shared arm can only CLEAR the field; 1 is a no-op, 0 disables",
"enableObjectivesAsManagerTasks": "same shared arm as enableObjectives",
"clientKeepAliveResetTimeoutSec": "reprograms a client timer with value*1000",
"getOperationTimeoutSec": "reprograms a client timer with value*1000",
"checkServerDbVersion": "makes the client go read a server_db_version config",
}
def main():
fails = []
atoms = {}
with open(os.path.join(HERE, "..", "docs", "fut_atoms.tsv")) as fh:
for line in fh:
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
try:
atoms[p[2]] = int(p[1], 16)
except ValueError:
pass
# Cross-check the recovered table against the atom table both ways.
by_name = {v: k for k, v in SWITCH_ARMS.items()}
for name, aid in by_name.items():
if name not in atoms:
fails.append("switch arm %s (%#x) is not in fut_atoms.tsv" % (name, aid))
elif atoms[name] != aid:
fails.append("%s: switch says %#x, atom table says %#x"
% (name, aid, atoms[name]))
import utas_server as u
body = u.SETTINGS
if not isinstance(body, dict) or list(body) != ["configs"]:
fails.append("body must be exactly {'configs': [...]}, got %r" % (body,))
return report(fails)
rows = body["configs"]
if not isinstance(rows, list):
fails.append("configs must be a LIST (a scalar here desyncs the parser)")
return report(fails)
seen = set()
for r in rows:
if not isinstance(r, dict) or set(r) != {"type", "value"}:
fails.append("row must be exactly {type, value}: %r" % (r,))
continue
t, v = r["type"], r["value"]
# value: any scalar is safe (getter 0x1801c79d0 coerces int/float/bool/str),
# but the applier tests `== 1`, so a bool True would work and a string "1"
# would work -- ints keep it unambiguous. An object or array FREEZES.
if isinstance(v, (dict, list)):
fails.append("%s: value is %s -- an object/array here FREEZES the client"
% (t, type(v).__name__))
if not isinstance(t, str):
fails.append("type must be a string, got %r" % (t,))
continue
if t in seen:
fails.append("%s sent twice; last one wins, so this is at best confusing" % t)
seen.add(t)
if t not in by_name:
hint = " (it IS an atom, but has no arm in the switch)" if t in atoms else ""
fails.append("%s has no arm in 0x18013c6d0 -- INERT%s" % (t, hint))
elif t in SPECIAL:
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
# Read the mode off the server module, never re-declare the default here: a
# checker with its own copy of a default tests the copy, not the server.
mode = u._SETTINGS_MODE
if mode == "gates":
for g in sorted(gates - seen):
fails.append("mode 'gates' but %s is missing" % g)
for t in sorted(seen & gates):
row = next(r for r in rows if r["type"] == t)
if row["value"] != 1:
fails.append("%s = %r; the applier tests `== 1`, nothing else opens "
"the gate" % (t, row["value"]))
print(" mode=%s, %d rows, %d distinct flags, all with a live switch arm"
% (mode, len(rows), len(seen)))
return report(fails)
def report(fails):
if fails:
print("\nFAIL (%d)" % len(fails))
for f in fails:
print(" - %s" % f)
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+46 -13
View File
@@ -47,16 +47,30 @@ _DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
# families were pointed at the trophy range.
#
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
# club-item id "cannot announce itself", and for these three that is false. A wrong
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
FAMILIES = [
("balls", "fcc_balls.json", 37, 0x1E, "balls", 149),
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148),
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145),
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146),
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150),
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
]
# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf().
CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150)
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
# probe route the docs preferred would have spent a launch and returned nothing for
# three of the five families.
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
# How many of each family the starter club owns. Small on purpose: the point is to
# make the counter non-zero so the client asks, not to hand anyone a collection.
@@ -71,22 +85,35 @@ def _rows(fname):
return []
def _item(item_id, carddbid, cardassetid, subtype, extra=None):
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
"""One club item. Deliberately narrow: no rating, no position, no attributes,
no nation, no league, no team. A club item has none of those, and sending a
field the family does not have is how a wrong shape gets accepted and does
nothing."""
no nation, no league. A club item has none of those, and sending a field the
family does not have is how a wrong shape gets accepted and does nothing."""
it = {
"id": item_id,
"resourceId": carddbid,
"assetId": carddbid,
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
"cardsubtypeid": subtype,
"itemType": "club", # UNOBSERVED on the wire; see module docstring
"itemState": "free",
"owners": 1,
"untradeable": False,
}
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
# without teamid the name comes out as the caption alone. STADIUM (10) reads
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
# record +0x94: an established scalar field, not a new shape.
#
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
# response that crashed the client, and it was never bisected. `value` is the
# established suspect, because it is an OBJECT member elsewhere and a scalar where
# an object is expected is the 0x1801c7f1a busy loop, and that response also
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
# ONE family, with the subtypes now corrected. That is the narrow test the crash
# denied us, and it is why families are served one at a time.
if teamid is not None and subtype in (9, 11):
it["teamid"] = teamid
if extra:
it.update(extra)
return it
@@ -119,7 +146,13 @@ def shelf(next_id=CLUBITEM_ID_BASE, families=None):
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
# and then dies. Omission is safe; an unestablished field is not. None of
# the three was needed to draw a card.
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype))
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
# which are the two families whose caption is <name> + TeamName_Abbr15_
# <teamid>. It is the one field from the fcc row being reintroduced after
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
# the note in _item(). value and leagueid stay omitted.
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
teamid=r.get("teamid")))
nid += 1
out[name] = picked
return out
+214 -6
View File
@@ -19,6 +19,112 @@ from fut_account import ACCOUNT # single source of truth for identity/c
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
#
# quick_sell() used to pay an invented rating tier (600/300/150/50). That number
# was wrong for every card. The real table is `fcc_discardcoins` in the client's
# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the
# running client 2026-08-05 and verified against 22 live club items, 22/22 exact.
#
# The client computes the DISPLAYED value itself with the same table whenever our
# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item
# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the
# local computation when it is non-zero. So today the client shows the real value
# while the server pays a made-up one, and the two disagree on every card. This
# makes the paid value agree with the shown value.
#
# value = round_half_up(rating * price / 100)
# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3;
# derived from rating, NOT a wire field)
# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and
# checked across every subtype 0..599 with zero disagreements
#
# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits
# changes. Default off per the house rule, but this is the one patch worth
# defaulting on after a single verification.
# See docs/plan-2026-08-05-store-subsystem.md section 3.6.
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
_DP = {}
def _dp(ct, rares, p1, p2, p3):
for r in rares:
_DP[(ct, 1, r)] = p1
_DP[(ct, 2, r)] = p2
_DP[(ct, 3, r)] = p3
_dp(1, [0], 30, 150, 400)
_dp(1, [1], 75, 350, 800)
_dp(1, [7], 1500, 5000, 9000)
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
_dp(1, [4, 8, 9], 6000, 10000, 18000)
_dp(1, [11], 10000, 15000, 24000)
_dp(1, [5, 6], 20000, 40000, 80000)
_dp(1, [12], 120000, 120000, 120000)
_dp(2, [0], 20, 70, 110)
_dp(2, [1], 25, 120, 320)
for _ct in (3, 4, 5, 10):
_dp(_ct, [0], 10, 55, 110)
_dp(_ct, [1], 50, 100, 300)
for _ct in (6, 7, 8, 9):
_dp(_ct, [0], 5, 20, 40)
_dp(_ct, [1], 20, 50, 70)
def _cardtype(sub):
"""FUN_1800d8330. 0 means no table row, which the client renders as value 0."""
if sub is None:
return 0
if 0 <= sub <= 3:
return 1
if sub == 4:
return 2
if sub == 5:
return 3
if sub == 6:
return 10
if sub == 7:
return 5
if sub == 8:
return 4
if 9 <= sub <= 11:
return 7
if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
return 9
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
or (300 <= sub <= 341):
return 6
return 0
def discard_value(item):
"""round_half_up(rating * price / 100), price from fcc_discardcoins.
Returns None when the formula does not apply, so callers fall back instead of
paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA:
it was verified 22/22 against club items, all of which were rated players, and
`rating * price / 100` collapses to 0 for a staff card carrying no rating. Found
by running the whole save through it, where exactly one item (a staff card,
cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for
a card the previous code paid for is a regression, so unrated cards fall back.
What FUT really pays for staff and consumables is UNKNOWN and worth recovering;
the likely answer is the unscaled table price, but that is a guess and is not
shipped as one.
"""
r = item.get("rating")
if not r:
return None
ct = _cardtype(item.get("cardsubtypeid"))
r = int(r)
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
if not price:
return None # no table row: the client renders 0, we should not
n = r * price
return n // 100 + (1 if n % 100 >= 50 else 0)
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
# import-time snapshots and will NOT reflect a later adopt_from_auth().
@@ -62,9 +168,37 @@ ITEM_ID_BASE = 100000000
_SQUAD_FITNESS_TRAP = 219
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set.
#
# "Place on Transfer List" and "List on Transfer Market" are greyed out on every card,
# and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by
# FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND
# item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does
# CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag.
#
# THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding
# failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
# tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons
# (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the
# same walk. tradingEnabled is the only gate byte yet found that is not already 1, and
# it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because
# _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this.
#
# Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the
# BOOL primitive FUN_1801c7620, and we already send the key on every card; only the
# value changes. The constructor default for +0x49 is 1 (tradeable), so false moves
# the field toward the client's own default rather than away from it.
#
# Side effects, both permissive rather than restrictive: item+0x49 also feeds
# FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT,
# which gates squad submission in FUN_1800bba10 (today that takes the
# couldNotSubmitSquad branch).
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
cardsubtypeid=0, rareflag=1):
return {
return _with_discard({
"id": item_id,
"resourceId": (version << 24) | asset,
"assetId": asset,
@@ -82,10 +216,62 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"itemState": "free",
"owners": 1,
"untradeable": True,
"untradeable": not TRADEABLE,
"contract": 7,
"fitness": 99,
}
})
# discardValue is stamped HERE, inside the single item factory, so every path that
# builds an item gets it: pack contents, the starter grant, club reads and market
# listings alike. Stamping it at one call site would leave the reveal screen and
# the club showing different numbers for the same card.
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
# the same number the server pays.
#
# Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600
# for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen
# showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the
# figure was right and invisible, and the screen contradicted the wallet.
#
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
#
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
# object or array where a scalar was expected, never the reverse.
#
# Requires FUT_DISCARD_TABLE, since without the real table this would put the invented
# tier on screen and make a wrong number authoritative-looking rather than merely paid.
DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE
def _with_discard(it):
"""Apply the read-path flags to one item.
Two things, both of which MUST happen on read and not only at creation: the
saved profile holds 246 items minted long before either flag existed, and the
club route serves them straight out of the save. Stamping only in _item() left
the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by
reading the served JSON rather than by unit-testing the factory.
Callers pass a COPY, so the save is never mutated by a read.
"""
if DISCARD_SEND:
# Omit the key entirely when the formula does not apply, rather than sending
# 0: a 0 makes the client fall back to its own lookup, and the tile binds our
# value anyway, so 0 renders as 0.
v = discard_value(it)
if v:
it["discardValue"] = v
if TRADEABLE:
it["untradeable"] = False
return it
def _new_profile():
@@ -182,7 +368,13 @@ class Store:
return self.load()["coins"]
def items(self):
return self.load()["items"]
# Stamp discardValue on READ as well as on creation. _item() only covers cards
# minted from now on, and the save already holds 246 items built before the
# flag existed; without this the reveal screen would show real values while
# the club showed 0 for everything older. Stamped on the way out and NOT
# persisted, so the save stays clean and turning the flag off is a true revert.
its = self.load()["items"]
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
def add_items(self, new_items):
with _LOCK:
@@ -215,6 +407,15 @@ class Store:
dv = it.get("discardValue") or 0
if dv:
return int(dv)
if DISCARD_TABLE:
# The real table. Matches what the client displays once
# FUT_DISCARD_SEND puts the value on the wire.
v = discard_value(it)
if v is not None:
return v
# else: unrated card, formula does not apply, fall through
# The invented tier. Wrong for every card, kept only as the live-proven
# default until FUT_DISCARD_TABLE has been in front of the game once.
r = it.get("rating") or 0
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
with _LOCK:
@@ -299,9 +500,14 @@ class Store:
return moved
def purchased(self):
# Stamped on read exactly like items(). Leaving this out was a real defect:
# the pending pile is the ONE place a quick-sell value is actually read, so
# the club showed real numbers while the reveal screen showed 0 for anything
# already sitting in the pile. Found by a verification pass, not by testing.
"""Items still held in the purchased/unassigned pile (returned by
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
return self.load().get("purchased", [])
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
def active_squad(self):
sq = self.load()["squads"]
@@ -396,7 +602,9 @@ class Store:
return items
def last_pack(self):
return self.load().get("purchased", [])
# Same stamping as purchased(); this is the reveal-screen read path.
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Read the FutDataManagerImpl UI gate bytes out of the LIVE FIFA 17 client.
Why this exists: on 2026-08-05 the /settings gate plan concluded that
IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED had never been set true by
anything. Measured against the running client, both are 1, and have been all along.
The applier FUN_18011dc50 runs whether or not the configs array has content, and the
settings struct it is handed defaults these fields to 1. "Nothing populates the array"
is not "nothing writes the byte".
Read-only. Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
Nothing is assumed:
* the pid is resolved by exact /proc/*/comm match, never hardcoded
* the CardsDLL base is read from /proc/<pid>/maps, never cached across launches
(Wine copies the sections into anonymous memory, so only the 4 KiB PE header is
file-backed and `grep CardsDLL maps` returns exactly ONE line, which is easy to
misread as "barely mapped")
* the slide is PROVEN against the FNV atom-hash prologue at 0x180180d00, read from
the on-disk PE, before any other address is trusted
* each gate byte displacement is DECODED from its accessor stub (0f b6 81 <disp32>,
movzx eax, byte [rcx+disp32]) rather than taken from a table
Requires the client to have reached Ultimate Team, since CardsDLL loads only then.
Usage: python3 gate_byte_probe.py
"""
import os, struct, sys
pid=None
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm'%d).read().strip()=='FIFA17.exe': pid=int(d); break
except Exception: pass
assert pid, "not running"
print("pid", pid)
base=None
for ln in open('/proc/%d/maps'%pid):
if 'CardsDLL' in ln:
base=int(ln.split('-')[0],16); print("cardsdll map line:", ln.strip())
assert base
slide = base - 0x180000000
print("base %#x slide %#x" % (base, slide))
fd=os.open('/proc/%d/mem'%pid, os.O_RDONLY)
def rd(va,n): return os.pread(fd, n, va)
# control: FNV prologue, bytes taken from the on-disk PE
pe=open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll','rb').read()
# .text rva 0x1000 rawptr 0x400
def f(va): return va-0x180000000-0x1000+0x400
ctl_disk=pe[f(0x180180d00):f(0x180180d00)+32]
ctl_live=rd(0x180180d00+slide,32)
print("CONTROL FNV", "MATCH" if ctl_disk==ctl_live else "MISMATCH", ctl_live.hex())
# model singleton
dat=0x1802e6398+slide
obj=struct.unpack('<Q', rd(dat,8))[0]
print("DAT_1802e6398 ->", hex(obj))
vt=struct.unpack('<Q', rd(obj,8))[0]
print("vtable live %#x static %#x" % (vt, vt-slide))
for off,name in [(0x2b0,'friendlySeasons'),(0x2c8,'draftMode'),(0x2e0,'packOpeningAnimation')]:
slot=struct.unpack('<Q', rd(vt+off,8))[0]
stub=rd(slot,8)
disp=struct.unpack('<I', stub[3:7])[0] if stub[:3]==b'\x0f\xb6\x81' else None
val=rd(obj+disp,1)[0] if disp is not None else None
print(" slot +%#x -> %#x stub=%s disp=%s value=%s" % (off, slot-slide, stub.hex(), hex(disp) if disp else None, val))
# unopenedPacks total
print("model+0x20950 =", struct.unpack('<I', rd(obj+0x20950,4))[0])
os.close(fd)
@@ -0,0 +1,84 @@
"""ADVERSARIAL Q1.
HYPOTHESIS UNDER ATTACK (dim1 claim 3): "FUN_1800150d0 ... finds-or-creates a group by
an exact string compare on displayGroup.value", i.e. wire-record +0x00 holds
displayGroup.value.
WHY IT IS NOT PROVEN: live we serve description == displayGroup.value == the SAME
STRING for all three packs ("Bronze Pack"/"Gold Pack"/"Premium Gold"), so the live
group caption cannot distinguish displayGroup.value (atom 0xd9->0x377) from
description (atom 0xd1). If the key is actually `description`, recommendation #2
(serve displayGroup.value="gold") silently does nothing.
METHOD: decompile the 0x158 wire-record element deserializer 0x18013af30 IN FULL,
print len(src), and enumerate the atom dispatch. Explicitly search the raw
disassembly of the function for EVERY syntactic dispatch form the brief warns about:
== imm, != imm, switch case labels (jump table), and sub/dec ladders.
CONTROL: atom 0x20f (packType) is known-present (live pack model +0x38 = "BRONZE"),
so whatever form finds packType must also be applied to 0xd1/0xd9/0xda/0x2cb.
The control uses the SAME method (raw immediate scan over the same instruction
range), not a different one.
"""
import sys, traceback, re
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q1_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
ATOMS = {0x23:"assetId",0xd1:"description",0xd9:"displayGroup",0xda:"displayGroupAssetId",
0xdb:"displayGroupUseDefaultImage",0x15c:"id",0x20f:"packType",0x250:"priority",
0x2cb:"sortPriority",0x377:"value",0x36a:"useDefaultImage",0x260:"purchase"}
for target in (0x18013af30,):
f = func(target)
P("=== FUNCTION %s @ %#x body=%s ===" % (f.getName(), int(f.getEntryPoint().getOffset()), f.getBody()))
src = dec(target, 300)
P("len(src) =", len(src))
P("---- FULL DECOMPILE BEGIN ----")
P(src)
P("---- FULL DECOMPILE END ----")
# raw instruction scan of the whole function body for every atom immediate
P()
P("=== RAW INSTRUCTION SCAN over FUN_18013af30 body: all forms ===")
f = func(0x18013af30)
body = f.getBody()
it = listing.getInstructions(body, True)
ins = []
while it.hasNext():
i = it.next()
ins.append((int(i.getAddress().getOffset()), str(i.getMnemonicString()), str(i)))
P("instruction count:", len(ins))
# collect all immediates appearing anywhere in the text form
found = {}
for a, mn, txt in ins:
for m in re.finditer(r'0x([0-9a-fA-F]+)', txt):
v = int(m.group(1), 16)
if v in ATOMS:
found.setdefault(v, []).append((a, mn, txt))
for v in sorted(ATOMS):
lst = found.get(v, [])
P("atom %#05x %-28s hits=%d" % (v, ATOMS[v], len(lst)))
for a, mn, txt in lst:
P(" %#x %s" % (a, txt))
# dispatch-form census: CMP/SUB/DEC ladders on the atom register
P()
P("=== dispatch-form census (CMP/SUB/DEC/SWITCH inside the function) ===")
forms = {"CMP":0,"SUB":0,"DEC":0,"JMP":0,"SWITCH":0}
for a, mn, txt in ins:
if mn in forms: forms[mn]+=1
if mn == "JMP" and "[" in txt: forms["SWITCH"]+=1
P(forms)
P("all CMP with a small immediate (candidate atom compares):")
for a, mn, txt in ins:
if mn in ("CMP","SUB","DEC","ADD") :
m = re.search(r'0x([0-9a-fA-F]{1,4})\s*$', txt)
if m:
v=int(m.group(1),16)
if 0x10 <= v <= 0x400:
P(" %#x %-8s %s -> imm %#x %s" % (a, mn, txt, v, ATOMS.get(v,"")))
fh.close()
except Exception:
traceback.print_exc()
@@ -0,0 +1,97 @@
"""ADVERSARIAL Q2. Batch.
Targets under attack:
(a) dim1 claim 4: "FUN_1800147f0 ... a miss returns NULL and the caller then
dereferences address 0x40, i.e. it would crash" -- ABSENCE OF A NULL CHECK.
Method: print the RAW DISASSEMBLY of FUN_1800147f0 from the CALL to
FUN_180014420 to the next 40 instructions, so a TEST/JZ is visible if present.
Control: the same raw-listing method applied to FUN_180014380's call sites,
where the decompiler DOES show a null test, must show TEST/JZ. Same form.
(b) dim1 claim 5: "+0x290 is written in exactly TWO places in all of CardsDLL".
objdump found 12 dword/qword writes at +0x290 plus one QWORD write at +0x28c
that covers it. Resolve the containing function of every one and decide.
(c) dim1 claim 9/10: model+0x94 = group ordinal, model+0x1a0 = sortPriority;
+0x1a0 pushed to no Flash field. Print FUN_18002c3c0 and FUN_180015d80 in full
and print their exact address ranges so the claim can be re-checked in objdump.
(d) dim1 claim 3: FUN_1800150d0 / FUN_180012950 / FUN_180014380 full.
(e) dim1 claim 7: FUN_180014580 / FUN_180014df0 six literals; enumerate.
(f) FUN_180014610 group-tile builder: does tile+0x9c really get the ordinal
(CHILD_CATEGORY) and tile+0xac the displayGroupAssetId? Recommendation #1
depends entirely on this.
"""
import sys, traceback, re
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q2_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
TARGETS = [0x1800150d0, 0x180012950, 0x180014380, 0x180014420, 0x1800147f0,
0x180014610, 0x18002c3c0, 0x180015d80, 0x180014580, 0x180014df0,
0x18007e7f0, 0x18007d1a0, 0x18007dab0]
P("=== FUNCTION BOUNDS ===")
for t in TARGETS:
f = func(t)
if f is None:
P("%#x -> NO FUNCTION" % t); continue
P("%#x %-22s min=%#x max=%#x size=%#x" % (t, f.getName(),
int(f.getBody().getMinAddress().getOffset()),
int(f.getBody().getMaxAddress().getOffset()),
int(f.getBody().getNumAddresses())))
# (b) resolve containing functions of every +0x290 write objdump found
P()
P("=== (b) containing functions of every raw +0x290 / +0x28c write ===")
W = [0x180051da3,0x18007d3ba,0x18007f0c0,0x18008c777,0x18008fd45,0x1800d3564,
0x1800d43fc,0x18013454a,0x180189d84,0x18018caa3,0x18018e1ff,0x180191f77,
0x18015b885,0x180067eb0,0x180067ebf]
for w in W:
f = func(w)
P(" %#x -> %s @ %#x" % (w, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0))
# is any of those functions in the store-screen vtable?
P()
P("=== store screen vtable 0x1801ff690 (first 48 slots) ===")
ents = set()
for off, tgt, nm in vtable(0x1801ff690, 48):
P(" +%#04x %#x %s" % (off, tgt, nm))
ents.add(tgt)
P("vtable also at 0x1801ff6f8 / 0x1801ff610 per the claim; dumping 0x1801ff610:")
for off, tgt, nm in vtable(0x1801ff610, 24):
P(" +%#04x %#x %s" % (off, tgt, nm))
# (a) raw disassembly around the FUN_180014420 call inside FUN_1800147f0
P()
P("=== (a) RAW LISTING of FUN_1800147f0 (whole function) ===")
f = func(0x1800147f0)
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
i = it.next(); n += 1
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
P("instruction count:", n)
P()
P("=== (a-control) RAW LISTING of FUN_180014610 (whole function) ===")
f = func(0x180014610)
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
i = it.next(); n += 1
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
P("instruction count:", n)
for t in TARGETS:
P()
f = func(t)
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,72 @@
"""ADVERSARIAL Q3.
Attacking dim1 claim 10: "sortPriority is inert at the UI. It reaches pack+0x1a0 and is
pushed to no Flash field ... Both are dead ends for this bug."
An objdump scan of the store cluster found 0x1800108cd/0x1800108d3
mov eax,[rsi+0x1a0] ; cmp [rbx+0x1a0],eax
which is the shape of a SORT COMPARATOR on two 0x1a8 models, and 0x18002cc62
mov [rbx+0x1a0],esi
inside FUN_18002cc90, which FUN_18002c3c0 tail-calls AFTER setting +0x1a0 = sortPriority.
Both were missed by "grep the push list".
Also decompile:
FUN_18002c8b0 -- the per-group filter in FUN_180014610; if it can HIDE a group the
tile ordinals the user sees stop matching the group ordinals.
FUN_18007e5e0 / FUN_18007df60 -- the six-panel binding (dim1 claim 7).
FUN_18007e7f0 cases 0x7551 / 0x753f -- the CATEGORY_ID round trip.
callers of FUN_1800147f0.
"""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q3_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
for a in (0x1800108cd, 0x18002cc62, 0x180010b5c, 0x180011c2c):
f = func(a)
P("%#x -> %s @ %#x size=%#x" % (a, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0,
int(f.getBody().getNumAddresses()) if f else 0))
P()
P("=== callers of FUN_1800147f0 ===")
for frm, typ, fn, ent in xrefs_to(0x1800147f0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_180014610 ===")
for frm, typ, fn, ent in xrefs_to(0x180014610):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_18002c8b0 ===")
for frm, typ, fn, ent in xrefs_to(0x18002c8b0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of the comparator's containing function ===")
cf = func(0x1800108cd)
if cf:
for frm, typ, fn, ent in xrefs_to(int(cf.getEntryPoint().getOffset())):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
tg = []
if cf: tg.append(int(cf.getEntryPoint().getOffset()))
tg += [0x18002cc90, 0x18002c8b0, 0x18007e5e0, 0x18007df60, 0x180014b60]
for t in tg:
f = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
# full FUN_18007e7f0 (big) -- print only, it is the CATEGORY_ID round trip
P()
P("======== DECOMPILE FUN_18007e7f0 (full) ========")
src = dec(0x18007e7f0, 600)
P("len(src) =", len(src))
P(src)
P("======== END ========")
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,29 @@
"""ADVERSARIAL Q4. Where is the +0x1a0 (sortPriority) merge sort actually used, and
what does the 0x1a8 ctor leave in +0x1a0 / +0x94 for GROUP TILES (FUN_180014610 sets
neither)? Also FUN_180012950 and FUN_180014380 in full for the group-key claim."""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q4_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
P("=== callers of FUN_180010cd0 (the merge-sort driver over +0x1a0) ===")
for frm, typ, fn, ent in xrefs_to(0x180010cd0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_180010890 ===")
for frm, typ, fn, ent in xrefs_to(0x180010890):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
for t in (0x1800130c0, 0x180012950, 0x180014380, 0x180010cd0):
f = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,30 @@
"""ADVERSARIAL Q5. The sortPriority merge sort has exactly one entry point
(0x180016f81 -> FUN_180010bc0). Identify its containing function, what list it sorts,
and who calls it. Also print FUN_1800130c0 in full to see whether +0x1a0 / +0x94 are
initialised at all for group tiles (FUN_180014610 sets neither)."""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q5_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
f = func(0x180016f81)
P("0x180016f81 is inside %s @ %#x size=%#x" % (f.getName(), int(f.getEntryPoint().getOffset()),
int(f.getBody().getNumAddresses())))
ent = int(f.getEntryPoint().getOffset())
P("=== callers of %s ===" % f.getName())
for frm, typ, fn, e in xrefs_to(ent):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, e))
for t in (ent, 0x1800130c0):
g = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (g.getName(), t))
src = dec(t, 300)
P("len(src) =", len(src)); P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,104 @@
"""ADVERSARIAL BATCH 1.
HYPOTHESES UNDER ATTACK (all from another agent, assumed WRONG until reproduced):
H1 item+0x49 = (untradeable == false), written by atom 0x361 in FUN_18013fe00.
H2 FUN_1801a7260 (TO_TRADE_PILE) requires item+0x49 != 0, and the eight flags are
ENABLE flags.
H3 FUN_18003e370 publishes 8 names in the order DISCARD, MODIFY, TO_ACTIVE_SQUAD,
TO_TRADE_PILE, ... and FUN_1800e2a40 fills those 8 bytes in that order.
H4 item+0x54 is the discard LEVEL written at 0x180141e8a..0x180141ea3, not itemType.
H5 the itemState table starts at 0x180229cc0 with 12 entries.
H6 FUN_180166660 has exactly one caller.
H7 FUN_1801a8620 (+0x38) and FUN_1801a8090 (+0x3c) have exactly one xref each.
CONTROL: for every "exactly one caller" claim I also run the SAME xrefs_to call on a
function that is known to have many callers (FUN_180135ff0, the value-SKIP, ~134) and
on the FNV hasher 0x180180d00, so a zero/one result cannot be a broken scan.
Everything is printed IN FULL; no truncation.
"""
import traceback, sys
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q1_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
f.write(s + "\n")
P("=" * 30, "CONTROL: xrefs machinery works", "=" * 30)
for nm, a in (("FUN_180135ff0 value-SKIP", 0x180135FF0),
("FUN_180180d00 FNV hasher", 0x180180D00),
("FUN_1801c7620 BOOL prim", 0x1801C7620)):
xr = xrefs_to(a)
ents = sorted(set(e for _, t, _, e in xr if "CALL" in t and e))
P("%s: %d refs, %d distinct calling funcs" % (nm, len(xr), len(ents)))
P()
P("=" * 30, "H7 discard getters", "=" * 30)
for nm, a in (("FUN_1801a8620 (+0x38 DISCARD_CREDITS?)", 0x1801A8620),
("FUN_1801a8090 (+0x3c CALCULATED?)", 0x1801A8090),
("FUN_1801a80c0 (CARD_LEVEL?)", 0x1801A80C0)):
P("---", nm)
fn = fm.getFunctionAt(addr(a))
P(" function at addr:", fn.getName() if fn else None)
for frm, t, cf, e in xrefs_to(a):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(" BODY:")
P(dec(a))
P()
P("=" * 30, "H6 FUN_180166660 callers", "=" * 30)
for frm, t, cf, e in xrefs_to(0x180166660):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(dec(0x180166660))
P()
P("=" * 30, "H5 itemState table walk from 0x180229c00", "=" * 30)
a = 0x180229C00
for i in range(40):
p = qword(a + i * 0x10)
q = qword(a + i * 0x10 + 8)
s = ""
if 0x180000000 <= p < 0x181000000:
try:
s = rd_str(p, 60)
except Exception:
s = "?"
P(" %#x p=%#018x q=%#018x %r" % (a + i * 0x10, p, q, s))
P()
P("=" * 30, "H2 TO_TRADE_PILE predicate + siblings", "=" * 30)
for a in (0x1801A7260, 0x1801A8940, 0x1801A71C0, 0x1801A7210, 0x1801A7250,
0x1801A7180, 0x1801A7320, 0x1801A71E0, 0x1801A8900, 0x1801A89F0):
fn = fm.getFunctionAt(addr(a))
P("### %#x %s xrefs=%d" % (a, fn.getName() if fn else "NO FUNC", len(xrefs_to(a))))
for frm, t, cf, e in xrefs_to(a):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(dec(a))
P()
P()
P("=" * 30, "H3 publisher + filler, FULL", "=" * 30)
for a in (0x18003E370, 0x1800E2A40):
P("### %#x len-of-decompile follows" % a)
d = dec(a)
P(" len(src) =", len(d))
P(d)
P()
P()
P("=" * 30, "H4 level write at 0x180141e60..0x180141ec0 raw disasm", "=" * 30)
ins = listing.getInstructions(addr(0x180141E40), True)
n = 0
while ins.hasNext() and n < 60:
i = ins.next()
if int(i.getAddress().getOffset()) > 0x180141EC0:
break
P(" %#x %s" % (int(i.getAddress().getOffset()), i))
n += 1
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,20 @@
"""BATCH 10: disassemble the undefined thunk at 0x18011c670 (slot +0x270 of the
0xed84b12 service = the second gate on TO_TRADE_PILE)."""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q10_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
P("bytes at 0x18011c670:", read_bytes(0x18011C670, 64).hex())
it = listing.getInstructions(addr(0x18011C670), True)
n = 0
while it.hasNext() and n < 40:
i = it.next(); a = int(i.getAddress().getOffset())
if a > 0x18011C6F0: break
P(" %#x %s" % (a, i)); n += 1
P()
for t in (0x18011C4C0, 0x18011C500):
P("### %#x" % t); P(dec(t)); P()
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,118 @@
"""ADVERSARIAL BATCH 2 -- the ABSENCE claims, re-tested with a DIFFERENT method.
The other agent tested "+0x49 is compared in exactly two places" and "itemState 5/6
are never tested" with a LOAD/COMPARE-PAIR scan keyed on displacement. That method
has a structural blind spot: a compare performed on a value RETURNED BY AN ACCESSOR
never shows the displacement at the compare site. FUN_1801a8940 is exactly such an
accessor for +0x49 and it has a caller (FUN_1800bc580) the agent never opened.
MY METHOD (different): enumerate EVERY instruction in .text whose textual form
contains the displacement, with no filter on opcode class at all -- so ==, !=, switch
case labels and sub/dec ladders are all caught at the LOAD, and the containing
function is then read. Plus a byte-pattern census of the two-instruction accessor
shape 48 8b 4x 18 / <load disp> which finds getters my displacement scan would
attribute to the getter rather than to its caller.
CONTROLS (same syntactic form as the targets -- a raw displacement load):
0x38 and 0x3c : known-live fields, must come back non-zero
0x4c : the other agent reported 37 pairs, must come back >= 37
0xdeadbe : impossible displacement, must come back 0 (proves the scan can
return zero for a real absence rather than always finding noise)
"""
import re, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q2_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
TARGETS = [0x38, 0x3c, 0x48, 0x49, 0x4c, 0x54, 0x58, 0x5c, 0x60, 0x88, 0x90]
pats = {d: re.compile(r"\+\s*0x%x\s*\]" % d) for d in TARGETS}
impossible = re.compile(r"\+\s*0xdeadbe\s*\]")
hits = {d: [] for d in TARGETS}
imp = []
n = 0
it = listing.getInstructions(True)
while it.hasNext():
i = it.next()
s = i.toString()
n += 1
for d, p in pats.items():
if p.search(s):
hits[d].append((int(i.getAddress().getOffset()), s))
if impossible.search(s):
imp.append(int(i.getAddress().getOffset()))
P("instructions scanned:", n)
P("IMPOSSIBLE-DISPLACEMENT CONTROL 0xdeadbe hits:", len(imp), "(must be 0)")
P()
for d in TARGETS:
fns = {}
for a, s in hits[d]:
fn = fm.getFunctionContaining(addr(a))
k = (fn.getName(), int(fn.getEntryPoint().getOffset())) if fn else ("?", 0)
fns.setdefault(k, []).append((a, s))
P("### displacement +0x%02x : %d instructions in %d functions" % (d, len(hits[d]), len(fns)))
if d in (0x49, 0x48):
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
P(" %s @%#x (%d)" % (nm, e, len(lst)))
for a, s in lst:
P(" %#x %s" % (a, s))
elif d == 0x5c:
P(" functions:")
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
P(" %s @%#x n=%d" % (nm, e, len(lst)))
P()
P("=" * 30, "+0x5c FULL instruction list (itemState 5/6 absence retest)", "=" * 30)
for a, s in hits[0x5C]:
fn = fm.getFunctionContaining(addr(a))
P(" %#x %-52s %s" % (a, s, fn.getName() if fn else "?"))
P()
P("=" * 30, "ACCESSOR CENSUS: byte pattern 48 8b 4x 18 followed by a load", "=" * 30)
seen = {}
for reg in (0x41, 0x51, 0x49, 0x59, 0x71, 0x79):
pat = bytes([0x48, 0x8B, reg, 0x18])
for a in find_all(pat, blocks=(".text",)):
try:
nxt = read_bytes(a + 4, 8)
except Exception:
continue
seen.setdefault(a, nxt)
P("call-shape candidates:", len(seen))
interest = {}
for a, nxt in seen.items():
disp = None
if nxt[0] == 0x8B and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
elif nxt[0] == 0x0F and nxt[1] in (0xB6, 0xB7) and (nxt[2] & 0xC0) == 0x40:
disp = nxt[3]
elif nxt[0] == 0x83 and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
elif nxt[0] == 0x8A and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
if disp in (0x38, 0x3C, 0x48, 0x49, 0x4C, 0x54, 0x58, 0x5C, 0x60, 0x88, 0x90):
fn = fm.getFunctionContaining(addr(a))
interest.setdefault(disp, []).append((a, fn.getName() if fn else "?",
int(fn.getEntryPoint().getOffset()) if fn else 0))
for d in sorted(interest):
P("### accessor-shape loads of +0x%02x : %d" % (d, len(interest[d])))
for a, nm, e in sorted(interest[d], key=lambda x: x[2]):
P(" %#x in %s @%#x" % (a, nm, e))
if e:
nc = [(fr, t, cf, ce) for fr, t, cf, ce in xrefs_to(e) if "CALL" in t]
P(" callers: %d -> %s" % (len(nc), sorted(set(cf for _, _, cf, _ in nc))))
P()
P("=" * 30, "THE UNOPENED +0x49 CONSUMER: FUN_1800bc580", "=" * 30)
d = dec(0x1800BC580)
P("len(src) =", len(d))
P(d)
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,100 @@
"""ADVERSARIAL BATCH 3.
My batch-2 displacement census turned up FOUR +0x5c sites the other agent's
constant-collecting scan did not report, including MOV dword [RDI+0x5c],0x5 and
MOV dword [RDI+0x5c],0x6 in FUN_180147070 -- i.e. the client WRITES forSale and
offered. Their claim "forSale(5) and offered(6): NEVER TESTED ANYWHERE" and the
action "nothing reads them" are under direct attack here.
Also under attack:
- "no other code path can produce the greyout from wire data": FUN_1800bc580 is a
THIRD +0x49 consumer (it counts untradeable squad members). What uses that count?
- the FUN_1800e2a40 <-> FUN_18003e370 vtable link the agent flagged as a gap.
- the +0x23f playStyle mapper, the 0x226 pile mapper, and the record-offset anchor
inside FUN_18013fe00 (printed IN FULL, with len).
CONTROL for the vtable hunt: I search for the 8-byte pointer to FUN_1800e2a40 AND,
in the same pass, for the pointer to FUN_1801a7260 (which the agent reported has NO
8-byte pointer, only 4-byte .pdata RVAs) and to FUN_18003e370. A hunt that finds all
three or none tells me the search itself is sound.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q3_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "A. itemState WRITERS/READERS the other scan missed", "=" * 25)
for a in (0x180147070, 0x1801A6FC0, 0x1800A47B0, 0x18011DC50, 0x1800D73D0):
d = dec(a)
P("### %#x len=%d xrefs:" % (a, len(d)))
for frm, t, cf, e in xrefs_to(a):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P(d)
P()
P("=" * 25, "B. the third +0x49 consumer: who calls FUN_1800bc580", "=" * 25)
for frm, t, cf, e in xrefs_to(0x1800BC580):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P("--- FUN_1801a8890 (the sibling predicate counted into param_2):")
P(dec(0x1801A8890))
P("--- FUN_1801a80a0:")
P(dec(0x1801A80A0))
P()
P("=" * 25, "C. vtable link FUN_1800e2a40 <- FUN_18003e370 slot 0x40", "=" * 25)
for nm, a in (("FUN_1800e2a40", 0x1800E2A40), ("FUN_1801a7260", 0x1801A7260),
("FUN_18003e370", 0x18003E370), ("FUN_1800eb850", 0x1800EB850)):
pat = struct.pack("<Q", a)
hits = find_all(pat, blocks=(".rdata", ".data"))
P(" %s ptr8 hits: %s" % (nm, [hex(h) for h in hits]))
for h in hits:
# walk backwards to find the table start (first qword that is not a .text ptr)
start = h
while True:
try:
v = qword(start - 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
break
start -= 8
P(" table start %#x, slot +%#x" % (start, h - start))
for i in range(0, 40):
try:
v = qword(start + i * 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
P(" +%#04x %#x <END>" % (i * 8, v))
break
fn = fm.getFunctionAt(addr(v))
P(" +%#04x %#x %s%s" % (i * 8, v, fn.getName() if fn else "",
" <== TARGET" if v == a else ""))
P()
P("=" * 25, "D. FUN_18013fe00 FULL", "=" * 25)
d = dec(0x18013FE00, timeout=600)
P("len(src) =", len(d))
P(d)
P()
P("=" * 25, "E. mappers", "=" * 25)
for nm, a in (("playStyle FUN_180136480", 0x180136480),
("pile FUN_180142650", 0x180142650),
("owners helper FUN_1800d7b50", 0x1800D7B50),
("BOUGHT_FOR mapper FUN_1800d7b30", 0x1800D7B30),
("family FUN_1800d8330", 0x1800D8330)):
P("### " + nm)
dd = dec(a)
P(" len=%d" % len(dd))
P(dd)
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,120 @@
"""ADVERSARIAL BATCH 4 -- the remaining serve-changing and absence claims.
- FUN_180141660: is the +0x54 level write really on the COMMON tail, or only on the
"DB Error" path? If only on the error path the whole level story changes.
- FUN_1801b3640: CMP dword [RAX+0x5c],R15D -- a REGISTER compare the other agent's
constant-collecting scan could not evaluate. If R15D can be 5 or 6 their
"forSale/offered are never tested" absence claim dies.
- FUN_18003e550: the listing panel. Does "List on Transfer Market" have its own
enable predicate the eight-flag array does not cover?
- FUN_1800eb850: are DISCARD_CREDITS / CALCULATED_DISCARD_CREDITS really the two
names, pushed from 0x1801a8620 / 0x1801a8090?
- 0x226 pile census, re-tested by xrefs to the mapper FUN_180142650 (a DIFFERENT
method from decompiling all 134 skip-callers).
- itemState string-writer absence, re-tested by xrefs to every one of the 12 string
literals, with the ITEM-TYPE table strings ('player','staff') as a control that
has known extra users.
- FUN_180008190: resolve the indirect string compare through the global vtable.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q4_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "A. FUN_180141660 -- is the level write a common tail?", "=" * 25)
fn = fm.getFunctionAt(addr(0x180141660))
body = fn.getBody()
P("body:", body, " min %#x max %#x" % (int(body.getMinAddress().getOffset()),
int(body.getMaxAddress().getOffset())))
# every RET in the function, and every branch target landing at/after 0x180141e77
rets, brs = [], []
it = listing.getInstructions(body, True)
while it.hasNext():
i = it.next()
m = i.getMnemonicString()
a = int(i.getAddress().getOffset())
if m == "RET":
rets.append(a)
if m.startswith("J"):
for r in i.getFlows():
t = int(r.getOffset())
if 0x180141E70 <= t <= 0x180141EB0:
brs.append((a, m, t))
P("RET sites:", [hex(x) for x in rets])
P("branches into the tail 0x180141e70..0x180141eb0:")
for a, m, t in brs:
P(" %#x %s -> %#x" % (a, m, t))
P()
P("FUN_180141660 decompile:")
d = dec(0x180141660, timeout=600)
P("len =", len(d))
P(d)
P()
P("=" * 25, "B. FUN_1801b3640 -- the register compare on +0x5c", "=" * 25)
ins = listing.getInstructions(addr(0x1801B3860), True)
n = 0
while ins.hasNext() and n < 90:
i = ins.next()
a = int(i.getAddress().getOffset())
if a > 0x1801B38E0:
break
P(" %#x %s" % (a, i))
n += 1
P()
P("R15 setup search 0x1801b3640..0x1801b3894:")
ins = listing.getInstructions(addr(0x1801B3640), True)
while ins.hasNext():
i = ins.next()
a = int(i.getAddress().getOffset())
if a > 0x1801B3894:
break
s = i.toString()
if "R15" in s:
P(" %#x %s" % (a, s))
P()
d = dec(0x1801B3640, timeout=600)
P("FUN_1801b3640 len =", len(d))
P(d)
P()
P("=" * 25, "C. FUN_18003e550 listing panel + FUN_1800eb850 discard push", "=" * 25)
for a in (0x18003E550, 0x1800EB850):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
P("=" * 25, "D. pile mapper xrefs (different method for the 0x226 census)", "=" * 25)
for frm, t, cf, e in xrefs_to(0x180142650):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P()
P("=" * 25, "E. itemState string literals: every xref", "=" * 25)
names = ["invalid", "free", "WAITING_FOR_GAME", "inGame", "forSale", "offered",
"activeBadge", "activeHomeKit", "activeAwayKit", "activeBall",
"activeStadium", "active",
"player", "staff"] # last two = CONTROL, known to be used elsewhere
for nm in names:
hits = find_all(nm.encode() + b"\x00", blocks=(".rdata", ".data"))
P("### %-18s literal hits: %s" % (nm, [hex(h) for h in hits]))
for h in hits:
for frm, t, cf, e in xrefs_to(h):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P()
P("=" * 25, "F. FUN_180008190 indirect compare + FUN_180130d10 + FUN_1801c3480", "=" * 25)
for a in (0x180008190, 0x180130D10, 0x1801C3480):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,62 @@
"""ADVERSARIAL BATCH 5 -- consequences of the one serve-changing action, and the
service gate the other agent left open.
1. untradeable:false flips item+0x49 to 1 on EVERY card. Besides TO_TRADE_PILE that
byte feeds FUN_1800bc580, which counts untradeable members of the 11-slot active
squad. Who consumes that count, and does flipping it change anything else?
2. FUN_1801a7260's other gate: slot +0x270 of the service FUN_180009c80 resolves.
Identify the service vtable and that slot if possible.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q5_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "1. consumers of the untradeable-squad count", "=" * 25)
for a in (0x1800BB2A0, 0x1800BBA10):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
P("=" * 25, "2. the service locator used by FUN_1801a7260", "=" * 25)
for nm, a in (("FUN_1800d7170", 0x1800D7170), ("FUN_180009c80", 0x180009C80),
("FUN_180018bd0", 0x180018BD0), ("FUN_180009b60", 0x180009B60)):
P("### " + nm)
P(dec(a))
P()
P("=" * 25, "3. any vtable with >= 0x280 bytes containing plausible slot 0x270", "=" * 25)
# find .rdata runs of >= 0x50 consecutive .text pointers; report those long enough
for b in mem.getBlocks():
if b.getName() != ".rdata" or not b.isInitialized():
continue
s = int(b.getStart().getOffset())
e = int(b.getEnd().getOffset())
a = (s + 7) & ~7
run_start = None
while a + 8 <= e:
try:
v = qword(a)
except Exception:
break
ok = 0x180001000 <= v < 0x1801E5000
if ok and run_start is None:
run_start = a
elif not ok and run_start is not None:
ln = a - run_start
if ln >= 0x280:
P(" vtable-ish run %#x..%#x len %#x slot+0x270 -> %#x %s" %
(run_start, a, ln, qword(run_start + 0x270),
(lambda fn: fn.getName() if fn else "")(fm.getFunctionAt(addr(qword(run_start + 0x270))))))
run_start = None
a += 8
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,52 @@
"""ADVERSARIAL BATCH 6 -- pin the service behind GUID 0xed84b11/0xed84b12 whose
vtable slot +0x270 is the OTHER gate on TO_TRADE_PILE. If that gate is an online /
transfer-market-availability check it may block the menu even with untradeable:false,
which is the single biggest risk to the headline recommendation.
METHOD: the class that implements an interface references the same GUID constant when
it registers. Scan .text for the 4-byte immediates and report every function.
CONTROL: the same scan for 0x10c80b95 (the CardInventory-ish service FUN_18003e370
uses) and 0xed80ed8 -- if those come back with registrars and 0xed84b11 does not, the
absence is about this GUID and not about the scan.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q6_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
for g in (0xED84B11, 0xED84B12, 0x10C80B95, 0x10C80B96, 0xED80ED8):
pat = struct.pack("<I", g)
hits = find_all(pat, blocks=(".text", ".rdata", ".data"))
fns = {}
for h in hits:
fn = fm.getFunctionContaining(addr(h))
k = fn.getName() if fn else "(data)"
fns.setdefault(k, []).append(h)
P("### GUID %#x : %d byte hits in %d functions" % (g, len(hits), len(fns)))
for k, v in sorted(fns.items()):
P(" %-24s %s" % (k, [hex(x) for x in v]))
P()
P("=" * 25, "the registrar bodies", "=" * 25)
seen = set()
for g in (0xED84B11, 0xED84B12):
for h in find_all(struct.pack("<I", g), blocks=(".text",)):
fn = fm.getFunctionContaining(addr(h))
if fn is None:
continue
e = int(fn.getEntryPoint().getOffset())
if e in seen:
continue
seen.add(e)
P("### %s @%#x" % (fn.getName(), e))
P(dec(e))
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,62 @@
"""ADVERSARIAL BATCH 7 -- finish the two open links.
(a) 0x10c80b96 appears as data at 0x1800e1662, inside the function at vtable slot
+0xa8 of the table 0x180215a80 -- the same table whose slot +0xd0 is
FUN_1800e2a40. If that holds it independently proves the FUN_18003e370 ->
FUN_1800e2a40 link the other agent could only infer semantically.
(b) 0xed84b12 appears as data at 0x180113f52. Whatever class that belongs to is the
service FUN_1801a7260 calls slot +0x270 on. Find its vtable and read slot 0x270.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q7_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
for a in (0x1800E1662, 0x180113F52):
fn = fm.getFunctionContaining(addr(a))
P("### data GUID at %#x -> containing function %s @%#x" %
(a, fn.getName() if fn else None,
int(fn.getEntryPoint().getOffset()) if fn else 0))
if fn:
e = int(fn.getEntryPoint().getOffset())
P(dec(e))
hits = find_all(struct.pack("<Q", e), blocks=(".rdata", ".data"))
P(" 8-byte pointer to it: %s" % [hex(h) for h in hits])
for h in hits:
start = h
while True:
try:
v = qword(start - 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
break
start -= 8
P(" run start %#x, this fn at slot +%#x" % (start, h - start))
# find the first non-stub entry -- the secondary vtable base
base = start
while qword(base) == 0x1801C577A:
base += 8
P(" first non-stub entry at %#x (offset +%#x from run start)" % (base, base - start))
P(" => slot of this fn relative to first non-stub: +%#x" % (h - base))
for i in range(0, 90):
v = qword(base + i * 8)
if not (0x180001000 <= v < 0x1801E5000):
break
f2 = fm.getFunctionAt(addr(v))
mark = ""
if i * 8 == 0x270:
mark = " <== SLOT 0x270"
if i * 8 == 0x40:
mark = " <== SLOT 0x40"
P(" +%#05x %#x %s%s" % (i * 8, v, f2.getName() if f2 else "", mark))
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""BATCH 8: the two GUID-returning stubs are undefined functions. Read them as raw
instructions and find the vtable that holds them. CONTROL: both stubs must decode to
'mov eax, <guid>; ret' -- if they do not, my reading of them as interface-id getters
is wrong and I say so."""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q8_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
for lo, hi in ((0x1800E1650, 0x1800E1690), (0x180113F40, 0x180113F80)):
P("### raw %#x..%#x" % (lo, hi))
P(" bytes:", read_bytes(lo, hi - lo).hex())
it = listing.getInstructions(addr(lo), True)
while it.hasNext():
i = it.next()
a = int(i.getAddress().getOffset())
if a >= hi: break
P(" %#x %s" % (a, i))
P()
for cand in (0x1800E1660, 0x180113F50, 0x180113F4C, 0x180113F40):
hits = find_all(struct.pack("<Q", cand), blocks=(".rdata", ".data"))
P("ptr8 to %#x : %s" % (cand, [hex(h) for h in hits]))
for h in hits:
start = h
while True:
try: v = qword(start - 8)
except Exception: break
if not (0x180001000 <= v < 0x1801E5000): break
start -= 8
base = start
while qword(base) == 0x1801C577A: base += 8
P(" run %#x, first non-stub %#x, this at +%#x from non-stub" % (start, base, h - base))
for i in range(0, 100):
v = qword(base + i * 8)
if not (0x180001000 <= v < 0x1801E5000): break
fn = fm.getFunctionAt(addr(v))
if i*8 in (0x40, 0x270, 0x308, 0x290, 0x2b0, 0x148, 0xd0, 0x20):
P(" +%#05x %#x %s" % (i*8, v, fn.getName() if fn else ""))
P(" table length: %#x" % (i*8))
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""BATCH 9. The cast helper is vtable slot +0x18 (FUN_180009c80 calls
(*(*svc))[0x18] with the interface GUID). So vtable_base = cast_stub_slot_addr - 0x18.
- 0x10c80b96 class: stub ptr at 0x180215b28 -> base 0x180215b10 -> slot +0x40 must be
FUN_1800e2a40 if the FUN_18003e370 link is real. (CONTROL for the arithmetic.)
- 0xed84b12 class: stub ptr at 0x18021c2b8 -> base 0x18021c2a0 -> slot +0x270 is the
other gate on TO_TRADE_PILE.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q9_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
for nm, base, slots in (("iface 0x10c80b96 (CONTROL)", 0x180215B10, (0x18, 0x40)),
("iface 0xed84b12", 0x18021C2A0, (0x18, 0x270, 0x290, 0x2b0, 0x308, 0x148))):
P("### %s vtable base %#x" % (nm, base))
for s in slots:
v = qword(base + s)
fn = fm.getFunctionAt(addr(v))
P(" +%#05x -> %#x %s" % (s, v, fn.getName() if fn else ""))
P()
for a in (0x1801B1CE0,):
pass
v = qword(0x18021C2A0 + 0x270)
P("=== slot 0x270 body ===")
P(dec(v, timeout=300))
P("=== xrefs to it ===")
for frm, t, cf, e in xrefs_to(v):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,74 @@
"""ADVERSARIAL BATCH 1.
HYPOTHESES UNDER TEST (all from another agent, assumed WRONG until reproduced):
H1 FUN_1800d8330 maps cardsubtypeid -> cardtype and returns 9 for exactly
{0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}; and returns 7 for 9,10,11.
H2 FUN_180119bd0 arms: 9 -> KITS, 10 -> Stadium, 0xb -> Badge, else "".
H3 FUN_1801a8640 == *(u32*)(*(u64*)(param_1+0x18)+0x50) i.e. cardsubtypeid.
H4 FUN_1800f6c40 calls vtable+0x498 only when item+0x4c == 7, args
(item+0x50, item+0x94, item+0x20); and sets IS_KIT_%d when item+0x50==9.
H5 FUN_180141660 tail writes item+0x54 = level(rating@+0xb4): 3 if >=0x4b,
else 2 - (rating < 0x41). <-- CONTRADICTS the live-map "+0x54 = itemType".
CONTROL: FUN_1800d8330 must decompile non-empty and its case labels must be
recoverable; it is a jump table, which is the form that DEFEATED an earlier scan.
Every decompile is written to disk IN FULL with its length printed, so no claim
here can rest on a truncated body.
Output: /tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
os.makedirs(OUT, exist_ok=True)
TARGETS = {
"FUN_1800d8330": 0x1800d8330,
"FUN_180119bd0": 0x180119bd0,
"FUN_1801a8640": 0x1801a8640,
"FUN_1800f6c40": 0x1800f6c40,
"FUN_180141660": 0x180141660,
"FUN_1801a8570": 0x1801a8570,
"FUN_1801a8560": 0x1801a8560,
"FUN_1801a8800": 0x1801a8800,
"FUN_1801a8040": 0x1801a8040,
"FUN_180136480": 0x180136480,
}
for name, a in TARGETS.items():
src = dec(a)
p = os.path.join(OUT, name + ".c")
open(p, "w").write(src)
print("WROTE %-16s len=%6d -> %s" % (name, len(src), p))
print()
print("=== small functions printed IN FULL ===")
for name in ("FUN_1800d8330", "FUN_1801a8640", "FUN_1801a8570", "FUN_1801a8560",
"FUN_1801a8800", "FUN_1801a8040", "FUN_180119bd0"):
src = open(os.path.join(OUT, name + ".c")).read()
print("\n----------8<---------- %s (len=%d) ----------" % (name, len(src)))
print(src)
print()
print("=== CONTROL: case labels of FUN_1800d8330 via the listing ===")
f = func(0x1800d8330)
print("entry 0x%x body %s" % (int(f.getEntryPoint().getOffset()), f.getBody()))
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
ins = it.next()
n += 1
print("instruction count: %d" % n)
# enumerate caseD_ labels inside the body
st = prog.getSymbolTable()
labs = []
rng = f.getBody()
for sym in st.getAllSymbols(True):
a2 = sym.getAddress()
if a2 is not None and rng.contains(a2) and str(sym.getName()).startswith("caseD_"):
labs.append((str(sym.getName()), int(a2.getOffset())))
print("caseD_ labels in FUN_1800d8330: %d -> %s" % (len(labs), sorted(set(l[0] for l in labs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,124 @@
"""ADVERSARIAL BATCH 2.
MAIN ATTACK: the claim "cardtype 9 has NO resolver at all, so ball and leaguelogo
display strings must come off the wire (localizedName + description)". That claim
CHANGES WHAT WE SERVE, so it is priority 1.
Counter-evidence to chase: .rdata at 0x1802041d0 holds 'fcc_leaguelogos' and
0x1802041e0 holds 'LeagueName_Abbr_15_%d', sitting immediately beside 'FUT_UC_KITS'
(0x180204180) which IS a resolver literal. If some function formats
LeagueName_Abbr_15_%d for a league logo, the "must come off the wire" claim is wrong.
H6 vtable+0x490 = FUN_18011a860 is a GENERIC name resolver taking
(cardtype@+0x4c, cardsubtypeid@+0x50, resourceId@+0x18). Does it have a
cardtype-9 arm?
H7 'fcc_leaguelogos' / 'LeagueName_Abbr_15_%d' are referenced by some function.
H8 FUN_18012ee20 has EXACTLY ONE caller (the club URL builder). [absence claim]
H9 FUN_1800fed90 is the ONLY function whose switch case set is exactly
{0x91..0x96}. [absence claim -- re-tested here by a DIFFERENT method than
the original caseD_ symbol enumeration: I enumerate switch tables from the
instruction/flow side via getBasicBlocks + scalar operands, AND repeat the
symbol method, and compare the two.]
H10 FUN_180141660 (the merge) is called on every deserialized item.
CONTROL for the xref questions: 'FUT_UC_KITS' at 0x180204180 MUST come back with
>=1 referencing function (we already know FUN_180119bd0 uses it). If the xref
method returns 0 for FUT_UC_KITS the method is broken and every negative is void.
Same syntactic form (a .rdata string address referenced by a LEA) as the targets.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
print("=== CONTROL + targets: xrefs to .rdata string addresses ===")
STRS = {
"FUT_UC_KITS (CONTROL)": 0x180204180,
"FUT_UC_BALL": 0x180239120,
"fcc_leaguelogos": 0x1802041d0,
"LeagueName_Abbr_15_%d": 0x1802041e0,
"leagues": 0x1802041b0,
"Badge (0x1802041b8)": 0x1802041b8,
"countryid": 0x1802041c0,
"fcc_myclubs": 0x180204190,
"TeamName_Abbr15_%d?": None,
}
for name, a in STRS.items():
if a is None:
continue
try:
xs = xrefs_to(a)
except Exception as e:
print(" %-24s XREF ERROR %s" % (name, e)); continue
fns = sorted(set((x[2], x[3]) for x in xs))
print(" %-24s 0x%x %d refs, funcs: %s" %
(name, a, len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
print()
print("=== find TeamName_Abbr15_%d and StadiumName_%d addresses then xref ===")
for lit in (b"TeamName_Abbr15_%d\x00", b"StadiumName_%d\x00", b"LeagueName_Abbr_15_%d\x00",
b"fcc_leaguelogos\x00", b"fcc_balls\x00", b"fcc_stadium\x00",
b"fcc_badgecards\x00", b"fcc_kitcards\x00", b"fcc_misccards\x00"):
hits = find_all(lit, blocks=(".rdata", ".data", ".text"))
print(" %-26s %d hit(s) at %s" % (lit.rstrip(b"\x00").decode(), len(hits),
[hex(h) for h in hits]))
for h in hits:
xs = xrefs_to(h)
fns = sorted(set((x[2], x[3]) for x in xs))
print(" -> %d refs: %s" % (len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
print()
print("=== H6: generic resolver FUN_18011a860 (vtable +0x490) FULL ===")
src = dec(0x18011a860)
open(os.path.join(OUT, "FUN_18011a860.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== H8: callers of FUN_18012ee20 (itemState code -> atom) ===")
for fa in (0x18012ee20, 0x180141660, 0x180166660, 0x1800fed90):
try:
cs = callers(fa)
except Exception:
cs = [(x[0], x[2], x[3]) for x in xrefs_to(fa)]
print(" FUN_%x callers: %s" % (fa, cs))
print()
print("=== H9: switch case-set enumeration, TWO methods ===")
st = prog.getSymbolTable()
# method 1: caseD_ symbols grouped by containing function
import collections
bysym = collections.defaultdict(set)
n = 0
for sym in st.getAllSymbols(True):
nm = str(sym.getName())
if not nm.startswith("caseD_"):
continue
n += 1
a2 = sym.getAddress()
f = fm.getFunctionContaining(a2)
if f is None:
continue
try:
v = int(nm.split("_")[-1], 16)
except ValueError:
continue
bysym[int(f.getEntryPoint().getOffset())].add(v)
print(" method1: %d caseD_ symbols over %d functions" % (n, len(bysym)))
TARGET = set(range(0x91, 0x97))
exact = [hex(k) for k, v in bysym.items() if v == TARGET]
superset = [hex(k) for k, v in bysym.items() if TARGET <= v and v != TARGET]
overlap = [hex(k) for k, v in bysym.items() if (TARGET & v) and not (TARGET <= v)]
print(" functions with case set EXACTLY {0x91..0x96}: %s" % exact)
print(" functions whose case set is a SUPERSET: %s" % superset)
print(" functions with PARTIAL overlap: %s" % overlap)
print(" CONTROL FUN_1800d8330 present in method1? %s -> %s" %
(0x1800d8330 in bysym, sorted(hex(x) for x in bysym.get(0x1800d8330, []))))
print()
print("=== H10: callers of the merge FUN_180141660 ===")
xs = xrefs_to(0x180141660)
print(" %d refs: %s" % (len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,92 @@
"""ADVERSARIAL BATCH 3.
PRIORITY-1 ATTACK: FUN_180098f20 is the ONLY referencer of both 'fcc_leaguelogos'
and 'LeagueName_Abbr_15_%d'. If it resolves a league-logo display name from the DB,
then the claim "cardtype 9 has no resolver at all, so ball and leaguelogo need
localizedName + description off the wire" is WRONG, and that claim changes what we
serve.
ALSO:
H11 FUN_180108c00 deserializes atom 0x32f (tournamentType) and computes
subtype = value + 0x91. (the trophy claim)
H12 FUN_1801bfac0 arm iVar5 == 0x1e -> FUT_UC_BALL, and the 0x1f arm.
H13 DAT_18022315c is the string "rare" (supports low-dword-of-uStack_130 = rareflag)
H14 the deser's stack struct -> record copy: which stack slot becomes record+0x58.
CONTROL for the "who calls X" questions: FUN_180119bd0 must come back with >=1
caller (we already proved FUN_1800f6c40 calls it through vtable slot +0x498 --
though that is an INDIRECT call, so a direct-xref method may legitimately return 0;
that is exactly why the control matters and why a 0 here is NOT an absence).
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
print("=== H13: strings at the DAT_ addresses used as DB column names ===")
for a in (0x18022315c, 0x1801eeeb0, 0x1802ef590, 0x18021ce7c, 0x18021ce7f, 0x1801e9caf):
try:
print(" 0x%x -> %r" % (a, rd_str(a, 40)))
except Exception as e:
print(" 0x%x -> ERR %s" % (a, e))
print()
print("=== PRIORITY 1: FUN_180098f20 FULL (the fcc_leaguelogos referencer) ===")
src = dec(0x180098f20)
open(os.path.join(OUT, "FUN_180098f20.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== who calls FUN_180098f20 ? ===")
for fa, label in ((0x180098f20, "leaguelogo resolver"),
(0x180119bd0, "CONTROL kit/stadium/badge resolver (indirect-only expected)"),
(0x18011a860, "generic resolver +0x490"),
(0x180094580, "third FUT_UC_KITS user"),
(0x1800991a0, "fcc_myclubs user"),
(0x180099490, "leagues/countryid/Badge user")):
xs = xrefs_to(fa)
print(" 0x%x %-52s %d refs: %s" %
(fa, label, len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
print()
print("=== H11: FUN_180108c00 FULL (tournamentType -> subtype 0x91+) ===")
src = dec(0x180108c00)
open(os.path.join(OUT, "FUN_180108c00.c"), "w").write(src)
print("len=%d" % len(src))
print(src[:9000])
if len(src) > 9000:
print("... [remainder in FUN_180108c00.c]")
print()
print("=== FUN_1800fed90 FULL (the 0x91..0x96 switch) ===")
src = dec(0x1800fed90)
open(os.path.join(OUT, "FUN_1800fed90.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== re-decompile the item deser MYSELF (do not trust the other agent's copy) ===")
src = dec(0x18013fe00, timeout=600)
p = os.path.join(OUT, "FUN_18013fe00.c")
open(p, "w").write(src)
print("len=%d -> %s" % (len(src), p))
# print only the lines that matter for H14
for i, ln in enumerate(src.splitlines(), 1):
if ("uStack_130" in ln or "local_100" in ln or "FUN_180141660" in ln
or "local_13c" in ln or "local_138" in ln):
print(" %4d: %s" % (i, ln))
print()
print("=== also dump the card-detail builder for the 0x1e / 0x1f arms ===")
src = dec(0x1801bfac0, timeout=600)
open(os.path.join(OUT, "FUN_1801bfac0.c"), "w").write(src)
print("len=%d" % len(src))
for i, ln in enumerate(src.splitlines(), 1):
if ("0x1e" in ln or "0x1f" in ln or "FUT_UC_BALL" in ln or "FUN_1801a8640" in ln
or "Stadium" in ln or "Badge" in ln or "FUT_UC_KITS" in ln
or "LeagueName" in ln or "fcc_" in ln):
print(" %4d: %s" % (i, ln))
except Exception:
traceback.print_exc()
@@ -0,0 +1,116 @@
"""ADVERSARIAL VERIFICATION BATCH 1.
HYPOTHESES UNDER ATTACK (from the D4 report):
H-A record+0x54 is card LEVEL derived from rating by an unconditional ladder in
the tail of FUN_180141660, NOT itemType.
H-B FUN_1801a87f0 is a one-byte read of record+0xb4 and all four OVERALL_RATING
publishers call it.
H-C playStyle lands at record+0x88, FUN_180136480 accepts only 0xfb..0x111.
H-D atom 0x173 itemType never becomes an int.
CONTROLS.
* For every "no such thing" statement I enumerate case labels, `== 0x`, `!= 0x`
AND sub/dec ladders, and I state which form the positive control used.
* Positive control for the dispatch enumeration: atoms 0x274 (rating) and 0x287
(resourceId), both known-present, must be found by the SAME enumerator.
* Positive control for the literal-xref method: a literal whose xref count is
independently known.
Everything is written to files; nothing is truncated.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
import re
def dump(name, s):
p = OUT + name
open(p, "w").write(s)
print("WROTE %s (%d chars)" % (p, len(s)))
targets = {
"merge_141660": 0x180141660,
"deser_13fe00": 0x18013FE00,
"playersmerge_135890": 0x180135890,
"acc_rating_1a87f0": 0x1801A87F0,
"acc_cardlevel_1a80c0": 0x1801A80C0,
"acc_playstyle_1a85c0": 0x1801A85C0,
"acc_league_1a8550": 0x1801A8550,
"acc_attr_1a8450": 0x1801A8450,
"acc_dream_1a8830": 0x1801A8830,
"acc_assetid_1a8010": 0x1801A8010,
"acc_asset2_1a8020": 0x1801A8020,
"mapper_playstyle_136480": 0x180136480,
"family_d8330": 0x1800D8330,
"resid_166ca0": 0x180166CA0,
}
blob = []
src = {}
for nm, a in targets.items():
f = func(a)
s = dec(a, 600)
src[nm] = s
blob.append("=" * 78)
blob.append("### %s @ %#x ghidra_fn=%s entry=%#x len=%d" % (
nm, a, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0, len(s)))
blob.append(s)
dump("v1_bodies.txt", "\n".join(blob))
# ---- dispatch-form enumeration over the item deser, ALL FOUR FORMS
d = src["deser_13fe00"]
print("\n--- deser FUN_18013fe00 len=%d ---" % len(d))
cases = sorted(set(int(x, 16) for x in re.findall(r"case\s+0x([0-9a-fA-F]+)", d)))
cases += sorted(set(int(x) for x in re.findall(r"case\s+(\d+)", d)))
eq = sorted(set(int(x, 16) for x in re.findall(r"==\s*0x([0-9a-fA-F]+)", d)))
ne = sorted(set(int(x, 16) for x in re.findall(r"!=\s*0x([0-9a-fA-F]+)", d)))
lt = sorted(set(int(x, 16) for x in re.findall(r"<\s*0x([0-9a-fA-F]+)", d)))
sub = sorted(set(int(x, 16) for x in re.findall(r"-\s*0x([0-9a-fA-F]+)", d)))
print("case labels (%d): %s" % (len(cases), [hex(c) for c in cases]))
print("== 0x (%d): %s" % (len(eq), [hex(c) for c in eq]))
print("!= 0x (%d): %s" % (len(ne), [hex(c) for c in ne]))
print("< 0x (%d): %s" % (len(lt), [hex(c) for c in lt]))
print("- 0x ladders (%d): %s" % (len(sub), [hex(c) for c in sub]))
for probe, label in [(0x274, "rating CONTROL"), (0x287, "resourceId CONTROL"),
(0x173, "itemType"), (0x23F, "playStyle"),
(0x172, "itemState"), (0x207, "owners"),
(0x361, "untradeable"), (0x1B, "amount"),
(0x226, "pile"), (0x6B, "cardassetid"), (0x23, "assetId"),
(0x18A, "leagueId"), (0x1D1, "nation"), (0x6C, "cardsubtypeid")]:
forms = []
if probe in cases:
forms.append("case")
if probe in eq:
forms.append("==")
if probe in ne:
forms.append("!=")
print(" atom %#x %-18s dispatch forms: %s" % (probe, label, forms or "NONE FOUND"))
# ---- who writes offset 0x54 anywhere in the two functions?
print("\n--- textual writes to +0x54 / 0x54 in merge and deser ---")
for nm in ("merge_141660", "deser_13fe00", "playersmerge_135890"):
for ln_no, ln in enumerate(src[nm].split("\n")):
if "0x54" in ln or "0xb4" in ln:
print(" %-20s %4d| %s" % (nm, ln_no, ln.strip()))
# ---- OVERALL_RATING literal: locate it MYSELF, then xref
print("\n--- OVERALL_RATING literal census ---")
hits = find_all(b"OVERALL_RATING\x00")
print("occurrences of 'OVERALL_RATING\\0':", [hex(h) for h in hits])
for h in hits:
xs = xrefs_to(h)
print(" %#x xrefs=%d" % (h, len(xs)))
for frm, t, fn, ent in xs:
print(" from %#x %s in %s @%#x" % (frm, t, fn, ent))
# control: a literal with an obviously different xref profile
for lit in (b"CARD_LEVEL\x00", b"PLAY_STYLE\x00", b"LEAGUE_ID\x00",
b"ATTRIBUTE_VALUE\x00", b"IS_DREAM_PLAYER\x00", b"ASSET_ID\x00"):
hs = find_all(lit)
print("\n%s occurrences: %s" % (lit, [hex(x) for x in hs]))
for h in hs:
xs = xrefs_to(h)
print(" %#x xrefs=%d -> %s" % (h, len(xs), sorted(set(x[2] for x in xs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,88 @@
"""ADVERSARIAL VERIFICATION BATCH 2.
Q1 COMPLETENESS GAP the D4 report admitted: are there raw, non-accessor reads of
record+0xb4 anywhere in the binary? 0xb4 cannot be encoded as a signed disp8,
so EVERY [reg+0xb4] reference must carry the literal disp32 bytes b4 00 00 00.
Scanning .text for those four bytes and decoding the containing instruction is
therefore an EXHAUSTIVE search, not a sample. Same scan for 0x54 and 0x88.
Positive control: the scan must find FUN_1801a87f0 (+0xb4), FUN_180141660's
ladder (+0xb4 and +0x54) and FUN_1801a85c0 (+0x88).
Q2 FUN_18013f4d0 -- the family-6 handler the deser tail calls with (record,
resourceId, AMOUNT). If it stores amount in the record, the standing
"amount is dropped" verdict is wrong.
Q3 the +0xe0 mystery: FUN_1801a8540, FUN_1800e5940 (manager publisher),
FUN_1800e6e20 (player publisher) in full.
Q4 FUN_180166660 itemState mapper, FUN_1800d7b50/b30/b10/af0 value readers.
Q5 who calls FUN_18013fe00 and FUN_180141660 (is the ladder really on every path).
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
def scan_disp(off):
pat = bytes([off & 0xFF, (off >> 8) & 0xFF, (off >> 16) & 0xFF, (off >> 24) & 0xFF])
hits = find_all(pat, blocks=(".text",))
rows = []
for h in hits:
ins = listing.getInstructionContaining(addr(h))
if ins is None:
continue
a = int(ins.getAddress().getOffset())
txt = str(ins)
if ("0xb4]" in txt or "0x54]" in txt or "0x88]" in txt or
hex(off) in txt.lower()):
f = fm.getFunctionContaining(ins.getAddress())
rows.append((a, txt, f.getName() if f else "?"))
return rows
for off, label in ((0xB4, "record+0xb4 rating"),
(0x54, "record+0x54 disputed"),
(0x88, "record+0x88 playStyle")):
rows = scan_disp(off)
print("\n==== EXHAUSTIVE disp32 scan for [reg+%#x] (%s): %d instructions"
% (off, label, len(rows)))
seen = {}
for a, txt, fn in rows:
seen.setdefault(fn, []).append((a, txt))
for fn in sorted(seen):
print(" %-24s" % fn, ["%#x %s" % (a, t) for a, t in seen[fn]])
bodies = []
for nm, a in (("f_13f4d0_family6", 0x18013F4D0),
("acc_1a8540", 0x1801A8540),
("acc_1a86b0", 0x1801A86B0),
("acc_1a8590_nation", 0x1801A8590),
("acc_1a86a0_team", 0x1801A86A0),
("pub_mgr_1800e5940", 0x1800E5940),
("pub_player_1800e6e20", 0x1800E6E20),
("itemstate_166660", 0x180166660),
("rd_d7b50", 0x1800D7B50), ("rd_d7b30", 0x1800D7B30),
("rd_d7b10", 0x1800D7B10), ("rd_d7af0", 0x1800D7AF0),
("stamp_d84e0", 0x1800D84E0)):
f = func(a)
s = dec(a, 600)
bodies.append("=" * 78)
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
bodies.append(s)
open(OUT + "v2_bodies.txt", "w").write("\n".join(bodies))
print("\nWROTE v2_bodies.txt")
print("\n==== callers ====")
for nm, a in (("FUN_18013fe00 item deser", 0x18013FE00),
("FUN_180141660 merge", 0x180141660),
("FUN_180135890 players merge", 0x180135890),
("FUN_1801a87f0 rating acc", 0x1801A87F0),
("FUN_1801a80c0 cardlevel acc", 0x1801A80C0),
("FUN_1801a85c0 playstyle acc", 0x1801A85C0),
("FUN_1801a8550 league acc", 0x1801A8550),
("FUN_1801a8540", 0x1801A8540)):
xs = xrefs_to(a)
cs = sorted(set("%s@%#x" % (x[2], x[3]) for x in xs if x[1].startswith("UNCONDITIONAL_CALL") or "CALL" in x[1]))
print("%-30s xrefs=%d callers=%s" % (nm, len(xs), cs))
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""ADVERSARIAL BATCH 3: exhaustive [reg+disp32] scan, tightened.
0xb4 / 0x54 / 0x88 / 0xe0 cannot be a signed disp8, so every [reg+off] reference
must carry the disp32 bytes literally. The scan is therefore exhaustive over .text.
Filter: keep only instructions whose printed operand ends in "+ 0x<off>]", drop LEA
and the unwind-stub noise.
Positive controls that MUST appear: FUN_1801a87f0 (+0xb4 read),
FUN_180141660 (+0xb4 read and +0x54 write), FUN_1801a85c0 (+0x88 read),
FUN_1801a80c0 (+0x54 read and write).
"""
import traceback
try:
for off in (0xB4, 0x54, 0x88, 0xE0):
pat = bytes([off, 0, 0, 0])
hits = find_all(pat, blocks=(".text",))
rows = []
for h in hits:
ins = listing.getInstructionContaining(addr(h))
if ins is None:
continue
txt = str(ins)
if ("+ %s]" % hex(off)) not in txt:
continue
mn = txt.split()[0]
if mn in ("LEA", "NOP"):
continue
f = fm.getFunctionContaining(ins.getAddress())
fn = f.getName() if f else "?"
if fn.startswith("Unwind") or fn.startswith("_guard"):
continue
rows.append((int(ins.getAddress().getOffset()), txt, fn))
rows = sorted(set(rows))
print("\n==== [reg+%#x] exhaustive disp32 scan: %d non-LEA, non-unwind instructions"
% (off, len(rows)))
byf = {}
for a, t, fn in rows:
byf.setdefault(fn, []).append((a, t))
for fn in sorted(byf):
print(" %-26s %s" % (fn, "; ".join("%#x %s" % x for x in byf[fn])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,79 @@
"""ADVERSARIAL BATCH 4.
CARD SIDE
A. FUN_1801aa7f0 and FUN_1800e6410 read [reg+0xb4] as a byte but sit OUTSIDE the
accessor range [0x1801a7000,0x1801a9000) the D4 report swept. Do they read an
item record? If so the "OVERALL_RATING has exactly four publishers, all through
FUN_1801a87f0" completeness argument has a hole.
B. FUN_1801356c0 -- the family-2 (manager) merge. Does it clobber +0xdd..+0xfb the
way the players merge does? That decides whether leagueId at +0xe0 survives for
managers.
C. FUN_180134cb0 -- writes +0xfc..+0x101, which FUN_1801a86b0 reads as the
per-attribute chemistry delta.
D. disp8 scan for [reg+0x54]: 0x54 fits a signed disp8 so the disp32 trick does
NOT apply; iterate EVERY instruction in .text instead. Positive control:
FUN_180141660 and FUN_1801a80c0 must appear.
ROUTE SIDE
E. FUN_18012ec50 club ?type= switch, FUN_18012f4f0 club/stats switch,
FUN_1801308c0 consumables suffix, FUN_18012ddf0 query builder -- full, so the
"exactly 30 / exactly 7 / no /stats/team" absences can be re-tested against
case labels AND == AND != AND ladders.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
import re
bodies = []
src = {}
for nm, a in (("rating_reader_1aa7f0", 0x1801AA7F0),
("rating_reader_e6410", 0x1800E6410),
("mgr_merge_1356c0", 0x1801356C0),
("chem_134cb0", 0x180134CB0),
("clubtype_12ec50", 0x18012EC50),
("clubstats_12f4f0", 0x18012F4F0),
("consum_1308c0", 0x1801308C0),
("clubsearch_12ddf0", 0x18012DDF0)):
s = dec(a, 600)
src[nm] = s
bodies.append("=" * 78)
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
bodies.append(s)
open(OUT + "v4_bodies.txt", "w").write("\n".join(bodies))
print("WROTE v4_bodies.txt")
for nm in ("clubtype_12ec50", "clubstats_12f4f0"):
s = src[nm]
cases = re.findall(r"case\s+(0x[0-9a-fA-F]+|\d+):", s)
eq = re.findall(r"==\s*(0x[0-9a-fA-F]+|\d+)", s)
ne = re.findall(r"!=\s*(0x[0-9a-fA-F]+|\d+)", s)
sub = re.findall(r"-\s*(0x[0-9a-fA-F]+|\d+)U?\s*<", s)
print("\n%s len=%d cases=%d %s\n ==%s !=%s ladders=%s"
% (nm, len(s), len(cases), cases, eq, ne, sub))
# ---- D: exhaustive instruction walk for [reg+0x54]
print("\n==== EVERY instruction in .text referencing [reg + 0x54] ====")
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
it = listing.getInstructions(blk.getStart(), True)
n = 0
found = []
while it.hasNext():
ins = it.next()
if ins.getAddress().getOffset() > int(blk.getEnd().getOffset()):
break
n += 1
t = str(ins)
if "+ 0x54]" in t:
f = fm.getFunctionContaining(ins.getAddress())
found.append((int(ins.getAddress().getOffset()), t,
f.getName() if f else "?"))
print("instructions walked: %d ; hits: %d" % (n, len(found)))
byf = {}
for a, t, fn in found:
byf.setdefault(fn, []).append("%#x %s" % (a, t))
for fn in sorted(byf):
print(" %-26s %s" % (fn, "; ".join(byf[fn])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,58 @@
"""Q1 recon: the itemState enum table and the club?type= strings.
HYPOTHESIS: the itemState enum table at 0x180229d20 (stride 0x10, 10 entries) is
referenced by (a) a string->enum mapper in the deserializer and (b) an equip path
that WRITES activeBadge/activeHomeKit/... The equip path is the place most likely
to switch on cardsubtypeid for cardtype 9.
CONTROL: the table dump itself. The doc states the ten names; if the dump does not
reproduce WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
activeAwayKit, activeBall, activeStadium, active in that order, my table read is
wrong and every conclusion downstream is void.
Also: locate the literals for club?type= singular names (stadium/ball/equippables)
and the family caption keys, with occurrence counts, so later queries can pick a
unique anchor.
"""
import traceback
try:
print("=== A: itemState enum table 0x180229d20, stride 0x10, 14 entries ===")
T = 0x180229D20
for i in range(14):
e = T + i * 0x10
q0 = qword(e)
q1 = qword(e + 8)
s = ""
if 0x180000000 <= q0 < 0x181000000:
try:
s = rd_str(q0, 64)
except Exception:
s = "?"
print(" [%2d] %#x: q0=%#018x %-24r q1=%#x" % (i, e, q0, s, q1))
print()
print("=== B: xrefs to the table start and to each row ===")
for i in range(12):
e = T + i * 0x10
xs = xrefs_to(e)
if xs:
print(" row %d @%#x:" % (i, e))
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== C: string literals of interest, all occurrences ===")
pats = [
b"activeBadge", b"activeHomeKit", b"activeAwayKit", b"activeBall",
b"activeStadium", b"itemState", b"forSale", b"inGame",
b"equippables", b"stadium", b"Stadium", b"ball", b"Ball",
b"badge", b"Badge", b"kit", b"Kit", b"clubLogo", b"leagueLogo",
b"CLUBLOGO", b"LEAGUELOGO", b"BADGE", b"STADIUM", b"BALL", b"KIT",
]
for p in pats:
hits = find_all(p)
print(" %-16r n=%d %s" % (p.decode(), len(hits),
" ".join("%#x" % h for h in hits[:12])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,56 @@
"""Q10: the fcc_ table vocabulary and the classifier's neighbourhood.
Q8/Q9 changed the picture: inside the item deserializer, cardtype 9 items whose
cardsubtypeid is in [0x91,0x95) take a custom-image path, and a SEPARATE
deserializer FUN_180108c00 computes subtype = wireValue + 0x91 and then picks the
loc format by range:
0x91 <= s < 0x95 -> "TOURNY_LOC_%d"
0x95 <= s < 0x97 -> "SEASON_LOC_%d"
so 0x91..0x96 look like TROPHIES, not badges/kits/stadia/balls. Also, cardtype 7
(subtypes 9,10,11) has an arm that defaults a field to 0x23 = 35, and 35 is the
kit cardassetid recorded in tools/fut_clubitems.py.
This query gathers the vocabulary needed to test that:
A. every "fcc_" table name literal in the binary, with the function that queries
it -- the merge's per-family table map;
B. every literal starting "cardsubtype" / "cardtype" (column names);
C. the small helpers around the classifier: FUN_1800d84e0 (called right after it
in the deser), FUN_1800d7b30/b50/af0/b10, FUN_1800d7170.
CONTROL: "fcc_discardcoins" must appear in A, and its query site must be
FUN_18013fe00 (line 784 of the Q8 decompile). If it does not, the literal scan is
not seeing the same code the decompiler is.
"""
import traceback
try:
print("=== A: fcc_ table literals ===")
seen = set()
for h in find_all(b"fcc_"):
s = rd_str(h, 64)
if not s or s in seen:
continue
seen.add(s)
xs = xrefs_to(h)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-28r <- %s" % (h, s, who or "-"))
print(" total distinct: %d" % len(seen))
print()
print("=== B: cardtype / cardsubtype column literals ===")
for pat in (b"cardtype", b"cardsubtype", b"carddbid", b"cardassetid"):
for h in find_all(pat):
s = rd_str(h, 64)
xs = xrefs_to(h)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-28r <- %s" % (h, s, who or "-"))
print()
print("=== C: helpers ===")
for a in (0x1800D84E0, 0x1800D7B30, 0x1800D7B50, 0x1800D7AF0, 0x1800D7B10):
src = dec(a)
print("-" * 70)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 2500 else src[:2500] + "\n...[TRUNCATED, len above]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,37 @@
"""Q11: dump the candidate functions to files for local analysis.
Rationale: the interesting functions are 3k-27k chars each and printing them all to
the transcript is wasteful. Write each decompile to
/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec/FUN_<addr>.c and print only name+length here.
Set chosen from Q10:
FUN_180098f20 queries fcc_leaguelogos AND uses carddbid + cardassetid
FUN_180098560 / FUN_1800989f0 / FUN_180042440 / FUN_180043350 fcc_myclubscategories
FUN_1800991a0 fcc_myclubs
FUN_180141660 the merge (carddbid)
FUN_18011a860 / FUN_1801356c0 / FUN_1801362e0 other carddbid users
FUN_18013fe00 the shared item deserializer (full, for local grep)
FUN_18011e9d0 the <0x95 callback from Q9
FUN_18013af30 the remaining scan hit
CONTROL: FUN_18013fe00 must come out at 26234 chars, the length Q8 measured. A
different length means a different function or a different decompiler setting.
"""
import os
import traceback
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
try:
os.makedirs(OUT, exist_ok=True)
for a in (0x180098F20, 0x180098560, 0x1800989F0, 0x180042440, 0x180043350,
0x1800991A0, 0x180141660, 0x18011A860, 0x1801356C0, 0x1801362E0,
0x18013FE00, 0x18011E9D0, 0x18013AF30, 0x180096670, 0x1801017E0):
src = dec(a)
p = os.path.join(OUT, "FUN_%x.c" % a)
with open(p, "w") as f:
f.write(src)
print(" %-14s len=%d -> %s" % ("FUN_%x" % a, len(src), p))
except Exception:
traceback.print_exc()
@@ -0,0 +1,76 @@
"""Q12: the UI group tables around 0x180203260 and every family caption key.
Known: consumables group table at 0x180203260 (7 rows, stride 0x18, indexed by the
switch in FUN_180096670 case 0xb) and staff at 0x180203310 (5 rows, case 8). The
club-item claim "there is no equivalent table" is exactly the kind of absence this
project keeps getting wrong, so walk the WHOLE region 0x180203100..0x180203700 as
stride-0x18 triples and print anything string-shaped, then xref each candidate
table start.
Also print every .rdata literal containing BADGE / STADIUM / BALL / KIT / LOGO /
TROPHY (upper case, i.e. loc keys) with its xrefs. Q4 of the brief.
CONTROL: the consumables table at 0x180203260 must come out as the seven rows
already recorded (TRAINING/CONTRACT/FITNESS/HEALING/PLAYSTYLE/MANAGER_LEAGUE/
TACTIC_TRAINING with codes 0,1,4,3,0x17,0x18,0x11). If the walk does not reproduce
it, the stride/layout assumption is wrong and nothing else in this query counts.
"""
import traceback
def walk(lo, hi, stride):
a = lo
while a < hi:
cells = []
for k in range(0, stride, 8):
try:
q = qword(a + k)
except Exception:
q = 0
s = ""
if 0x180000000 <= q < 0x181000000:
try:
t = rd_str(q, 80)
if t and all(0x20 <= ord(c) < 0x7F for c in t):
s = t
except Exception:
pass
cells.append("%#x%s" % (q, (" %r" % s) if s else ""))
print(" %#x %s" % (a, " | ".join(cells)))
a += stride
try:
print("=== stride-0x18 walk 0x180203200..0x180203460 ===")
walk(0x180203200, 0x180203460, 0x18)
print()
print("=== xrefs to plausible table starts ===")
for a in range(0x180203200, 0x180203460, 8):
xs = xrefs_to(a)
if xs:
print(" %#x:" % a)
for frm, typ, fn, ent in xs:
print(" %#x %s in %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== upper-case family loc keys ===")
seen = set()
for pat in (b"BADGE", b"STADIUM", b"BALL", b"KIT", b"LOGO", b"TROPHY"):
for h in find_all(pat):
# walk back to the start of the C string
p = h
for _ in range(80):
try:
if mem.getByte(addr(p - 1)) & 0xFF == 0:
break
except Exception:
break
p -= 1
s = rd_str(p, 120)
if p in seen or len(s) < 4:
continue
seen.add(p)
xs = xrefs_to(p)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-52r <- %s" % (p, s, who or "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""Q13: every FUT_MYCLUB_ loc key, and the table row that carries it.
Q12 reproduced the consumables table (control passed) and showed the staff table's
middle column IS the cardtype (manager 2, headcoach 3, fitnesscoach 4, gkcoach 0xa,
physio 5 -- exactly the merge's switch arms), and a trophies pair:
0x180203380 {0x05, 0, FUT_MYCLUB_OFFLINE_TROPHIES_EARNED}
0x180203398 {0x15, 1, FUT_MYCLUB_ONLINE_TROPHIES_EARNED}
If a badges/kits/stadia/balls row exists in the same shape, its middle column is the
answer. Enumerate EVERY FUT_MYCLUB_ literal, find the pointer to it in .rdata/.data,
and print the 0x18-byte row it sits in for all three possible cell positions, plus
the rows either side.
CONTROL: FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED must resolve to the row
{0, ptr, 'training'} at 0x180203260. Any layout guess that cannot reproduce that row
is wrong.
"""
import traceback
try:
keys = []
for h in find_all(b"FUT_MYCLUB_"):
s = rd_str(h, 120)
keys.append((h, s))
keys.sort()
print("=== %d FUT_MYCLUB_ literals ===" % len(keys))
for h, s in keys:
print(" %#x %r" % (h, s))
print()
print("=== pointer rows ===")
for h, s in keys:
ptrs = find_all(h.to_bytes(8, "little"), blocks=(".rdata", ".data"))
if not ptrs:
print(" %-46r no pointer" % s)
continue
for pa in ptrs:
ctx = []
for off in (-0x18, -0x10, -8, 0, 8, 0x10, 0x18):
try:
q = qword(pa + off)
except Exception:
continue
t = ""
if 0x180000000 <= q < 0x181000000:
try:
u = rd_str(q, 80)
if u and all(0x20 <= ord(c) < 0x7F for c in u):
t = u
except Exception:
pass
ctx.append("%+#5x:%#x%s" % (off, q, (" %r" % t) if t else ""))
print(" %-46r @%#x" % (s, pa))
print(" " + " ".join(ctx))
except Exception:
traceback.print_exc()
@@ -0,0 +1,41 @@
"""Q14: the club URL format strings and their builders.
Q6 found 'type=%s' at 0x180224c7a and 0x180224ff9 with no direct xref, which means
each is the TAIL of a longer literal whose start is what the code references. Dump
every C string in 0x180224a00..0x180225300 and 0x18021e200..0x18021e800 with xrefs,
so the club request builder can be identified and decompiled.
Also dump 0x180228400..0x18022b200 for the transfermarket/club parameter strings.
CONTROL: '&cat=%s' at 0x1802285b8 is already known to be referenced by
FUN_180162c90; it must show that xref here too.
"""
import traceback
def dump(lo, hi, tag):
print("=== %s %#x..%#x ===" % (tag, lo, hi))
p = lo
while p < hi:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 160)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-66r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
try:
dump(0x180224A00, 0x180225300, "club/url block")
dump(0x18021E200, 0x18021E800, "route table")
dump(0x180228400, 0x180229000, "params block")
except Exception:
traceback.print_exc()
@@ -0,0 +1,67 @@
"""Q15: find the equip path by the atoms it must name.
The itemState vocabulary is also in the atom table:
0xc activeAwayKit 0xd activeBadge 0xe activeBall 0x10 activeHomeKit
0x12 activeStadium 0xa active 0x12e free 0x164 inGame 0x1e5 offered
and the club ?type= taxonomy switch FUN_18012ec50 shows how a name reaches the wire:
FUN_180180cd0(atom) returns the atom's name string. So whatever chooses which of the
five active* states to send must call FUN_180180cd0 with 0xc/0xd/0xe/0x10/0x12, and
the choice is made from the item's family. That is the mapping the brief wants.
Method: enumerate every caller of FUN_180180cd0, decompile each once, and report the
call sites whose literal argument is one of the atoms of interest:
equip states 0xc 0xd 0xe 0x10 0x12
club families 0x49 badge, 0x179 kit, 0x2d8 stadium, 0x4d ball,
0x18d leaguelogos, 0x10a equippables, 0x4b badges, 0x4f balls,
0x17c kits, 0x18e leagueLogos, 0x2d7 stadia
Print the matching lines with context so the surrounding switch is visible.
CONTROL: FUN_18012ec50 is a known caller and must be reported with its family atoms
(0x49, 0x179, 0x2d8, 0x4d, 0x18d, 0x10a). If it is not in the output, the caller
enumeration or the literal matching is broken.
"""
import re
import traceback
WANT = {0xC: "activeAwayKit", 0xD: "activeBadge", 0xE: "activeBall",
0x10: "activeHomeKit", 0x12: "activeStadium", 0xA: "active",
0x12E: "free", 0x164: "inGame", 0x1E5: "offered",
0x49: "badge", 0x179: "kit", 0x2D8: "stadium", 0x4D: "ball",
0x18D: "leaguelogos", 0x10A: "equippables", 0x4B: "badges",
0x4F: "balls", 0x17C: "kits", 0x18E: "leagueLogos", 0x2D7: "stadia"}
try:
ents = {}
for frm, typ, fn, ent in xrefs_to(0x180180CD0):
if ent:
ents[ent] = fn
print("callers of FUN_180180cd0: %d" % len(ents))
pat = re.compile(r"FUN_180180cd0\((0x[0-9a-f]+|\d+)\)")
nhit = 0
for ent, fn in sorted(ents.items()):
src = dec(ent)
lines = src.splitlines()
found = []
for i, l in enumerate(lines):
for m in pat.finditer(l):
v = int(m.group(1), 0)
if v in WANT:
found.append((i, v))
if not found:
continue
nhit += 1
print("=" * 70)
print("%s @%#x len=%d atoms=%s" %
(fn, ent, len(src),
sorted({"%#x=%s" % (v, WANT[v]) for _i, v in found})))
shown = set()
for i, _v in found:
for j in range(max(0, i - 4), min(len(lines), i + 2)):
if j in shown:
continue
shown.add(j)
print(" %4d: %s" % (j, lines[j].strip()))
print(" ---")
print("functions with hits: %d" % nhit)
except Exception:
traceback.print_exc()
@@ -0,0 +1,35 @@
"""Q16: the equip path -- callers of the itemState serializer.
Q15 found FUN_18012ee20: itemState code -> atom name, with
1->free 2->inGame 5->0x1f8 6->offered 100->activeBadge 0x65->activeHomeKit
0x66->activeAwayKit 0x67->activeBall 0x68->activeStadium 0xff->active
Whoever CALLS it with 0x64..0x68 is the equip path, and the code that picks which of
those five to pass must know the item's family.
Dump: every caller of FUN_18012ee20 in full, plus FUN_18012ddf0 (the club URL
builder) in full, plus FUN_18012ec50's caller chain context.
CONTROL: FUN_18012ddf0 must contain the five-way if/else on *(param_1+0x30) that
Q15 printed (0xa badge, 0xb kit, 0x15 stadium, 0x16 ball, else equippables). If the
full decompile lacks it, this is not the same function.
"""
import traceback
try:
ents = {}
for frm, typ, fn, ent in xrefs_to(0x18012EE20):
if ent:
ents[ent] = fn
print("callers of FUN_18012ee20 (itemState->atom): %d -> %s" %
(len(ents), ["%s(%#x)" % (v, k) for k, v in ents.items()]))
for ent in sorted(ents):
src = dec(ent)
print("=" * 78)
print("CALLER %s @%#x len=%d" % (ents[ent], ent, len(src)))
print(src)
print("=" * 78)
src = dec(0x18012DDF0)
print("CLUB URL BUILDER FUN_18012ddf0 len=%d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,58 @@
"""Q17: enumerate EVERY switch case label in the binary, then find the ones that
distinguish club subtypes.
Q5's scalar scan failed its control because jump-table case labels are not
instruction immediates. Ghidra, however, names them: it creates symbols of the form
switchD_<addr>_caseD_<n> (and caseD_<n>) at each case target. Walking the symbol
table therefore enumerates switch dispatch in the one form a scalar scan cannot see.
Report every function whose case-value set intersects the club-subtype candidates
{0x1e,0x1f,9,10,11,0x91..0x96} and print the full case set for each.
CONTROL: FUN_1800d8330 must appear with case labels including 0x1e, 0x1f, 0x91..0x96,
0xe7..0xe9 and 0xec. If it does not, the symbol-based enumeration is broken and no
absence claim may be made from it.
"""
import re
import traceback
try:
st = prog.getSymbolTable()
it = st.getAllSymbols(True)
pat = re.compile(r"caseD_([0-9a-fA-F]+)$")
per = {}
n = 0
while it.hasNext():
s = it.next()
m = pat.search(s.getName())
if not m:
continue
n += 1
try:
v = int(m.group(1), 16)
except ValueError:
continue
f = fm.getFunctionContaining(s.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
per.setdefault(key, set()).add(v)
print("case labels found: %d in %d functions" % (n, len(per)))
CAND = {0x1E, 0x1F, 9, 10, 11, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
print()
print("=== functions whose case set meets the club-subtype candidates ===")
rows = []
for (name, ent), vals in per.items():
inter = vals & CAND
if len(inter) >= 2:
rows.append((len(inter), name, ent, vals))
rows.sort(reverse=True)
for k, name, ent, vals in rows:
print(" %-26s %#x hits=%d cases=%s" %
(name, ent, k, sorted("%#x" % v for v in vals)))
print()
print("=== control: FUN_1800d8330 ===")
for (name, ent), vals in per.items():
if ent == 0x1800D8330:
print(" YES cases=%s" % sorted("%#x" % v for v in vals))
except Exception:
traceback.print_exc()
@@ -0,0 +1,33 @@
"""Q18: FUN_1800fed90 -- a switch whose case set is EXACTLY {0x91..0x96}.
Q17's case-label enumeration (control passed on FUN_1800d8330) found exactly one
function whose switch discriminates the six high club subtypes and nothing else:
FUN_1800fed90. If 0x91..0x96 are trophies, this is where each one is turned into a
concrete thing, and the six arms should be distinguishable.
Also decompile FUN_1800f4bc0 and FUN_1800f2f70 (case sets 0xa..0x14, i.e. they
distinguish 10 and 11, the other two cardtype-7 subtypes) and FUN_1800d8260 /
FUN_1800d86c0 / FUN_1800d8b50 (small enum->string helpers next to the classifier).
CONTROL: FUN_1800d8b50 is called by the club URL builder FUN_18012ddf0 to render a
value for query key atom 0x243; it should decompile to a code->string table, which
is a known shape. If it comes out as something else, my reading of the URL builder
is wrong.
"""
import traceback
try:
for a in (0x1800FED90, 0x1800F4BC0, 0x1800F2F70, 0x1800D8260, 0x1800D86C0,
0x1800D8B50):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 9000 else src[:9000] + "\n...[cut at 9000, len above]")
print("=" * 78)
print("=== callers ===")
for a in (0x1800FED90, 0x1800F4BC0):
print(" callers of %#x:" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
except Exception:
traceback.print_exc()
@@ -0,0 +1,89 @@
"""Q19: every constant the code compares a +0x50 (cardsubtypeid) or +0x4c (cardtype)
field against.
The parsed item record has cardsubtypeid at +0x50 and cardtype at +0x4c. Instead of
searching for a constant (which misses jump tables) or for a syntactic form (which
misses != and ladders), search for the FIELD ACCESS and then collect every immediate
that touches the loaded register within the next 8 instructions, whatever the
mnemonic. Both the direct form (CMP dword [reg+0x50], imm) and the load-then-test
form (MOV r32,[reg+0x50]; SUB r32,imm; CMP r32,imm) are covered.
CONTROL: FUN_18011e3c0 is known to do `*(int *)(x + 0x50) - 0x91U < 6`, so it must
appear with 0x91 (and 6) attached to a +0x50 access. If the control is absent the
scan is broken and nothing may be concluded from what it does not find.
"""
import traceback
try:
block = None
for b in mem.getBlocks():
if b.getName() == ".text":
block = b
break
per = {}
it = listing.getInstructions(block.getStart(), True)
window = [] # [(reg_name, remaining_instrs)]
n = 0
while it.hasNext():
ins = it.next()
n += 1
txt = str(ins)
# 1) direct: memory operand with disp 0x50/0x4c and an immediate
for disp in ("0x50", "0x4c"):
if ("+ " + disp + "]") in txt or ("+" + disp + "]") in txt:
imms = []
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
imms.append(int(o.getValue()))
except Exception:
pass
f = fm.getFunctionContaining(ins.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
rec = per.setdefault(key, {"direct": set(), "near": set()})
for v in imms:
if v not in (0x50, 0x4C) and 0 <= v < 0x1000:
rec["direct"].add((disp, v))
# start a window: whatever register this instruction defines
for r in ins.getResultObjects():
window.append([str(r), 8, key, disp])
# 2) decay window and attach immediates that touch the tracked register
nxt = []
for w in window:
reg, left, key, disp = w
if left <= 0:
continue
if reg in txt:
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if 0 <= v < 0x1000:
per.setdefault(key, {"direct": set(), "near": set()})
per[key]["near"].add((disp, v))
w[1] = left - 1
nxt.append(w)
window = nxt[-40:]
print("instructions scanned: %d" % n)
print()
CAND = {9, 10, 11, 0x1E, 0x1F, 7, 0x91}
print("=== functions whose +0x50 / +0x4c constants meet {9,10,11,0x1e,0x1f,7,0x91} ===")
for (name, ent), rec in sorted(per.items()):
vals = rec["direct"] | rec["near"]
hit = {v for _d, v in vals} & CAND
if not hit:
continue
print(" %-24s %#x hits=%s" % (name, ent, sorted("%#x" % h for h in hit)))
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"])[:40])
print()
print("=== control FUN_18011e3c0 ===")
for (name, ent), rec in per.items():
if ent == 0x18011E3C0:
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,85 @@
"""Q2: two string clusters that look like family-name tables.
Q1 found 'badge' 0x18022a220, 'kit' 0x18022a228, 'leagueLogo' 0x18022a230,
'ball' 0x18022a278 packed together, and a second cluster 'badge' 0x1802303c8,
'ball' 0x1802303dc, 'equippables' 0x180230f48, 'leagueLogo' 0x180231608.
HYPOTHESIS: cluster 1 is the value list of a {name -> code} enum table like the
itemState one (stride 0x10: char* then int). Cluster 2 is the club?type= route
vocabulary.
CONTROL: the itemState table itself. My Q1 read started mid-table (row0 =
activeBadge with code 0x64, while the doc's list starts at WAITING_FOR_GAME), so
this query re-walks BACKWARDS from 0x180229d20 to find the real table start and
prints it in full. If the ten documented names do not appear in order, my table
walker is wrong.
Then: for every string in each cluster, find the .rdata qword that points at it
(the table row) and print the row's neighbours, plus xrefs.
"""
import traceback
def dump_strings(lo, hi, label):
print("=== strings %s %#x..%#x ===" % (label, lo, hi))
p = lo
while p < hi:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 96)
if len(s) >= 2:
print(" %#x %r" % (p, s))
p += max(1, len(s)) + 1
else:
p += 1
def walk_table(start, n, back=0):
print("--- table walk from %#x, %d rows (stride 0x10) ---" % (start, n))
for i in range(-back, n):
e = start + i * 0x10
try:
q0, q1 = qword(e), qword(e + 8)
except Exception:
continue
s = ""
if 0x180000000 <= q0 < 0x181000000:
try:
s = rd_str(q0, 64)
except Exception:
s = "?"
print(" [%3d] %#x ptr=%#x %-26r val=%#x" % (i, e, q0, s, q1))
try:
walk_table(0x180229D20, 8, back=14)
print()
dump_strings(0x18022A200, 0x18022A380, "cluster1")
print()
dump_strings(0x180230300, 0x180230420, "cluster2a")
print()
dump_strings(0x180230F00, 0x180230FA0, "cluster2b")
print()
dump_strings(0x180231380, 0x180231680, "cluster2c")
print()
print("=== xrefs / pointer-rows for cluster strings ===")
for name, a in [("badge", 0x18022A220), ("kit", 0x18022A228),
("leagueLogo", 0x18022A230), ("ball", 0x18022A278),
("badge2", 0x1802303C8), ("ball2", 0x1802303DC),
("equippables", 0x180230F48), ("leagueLogo2", 0x180231608),
("itemState", 0x180231490)]:
print(" %s @%#x" % (name, a))
for frm, typ, fn, ent in xrefs_to(a):
print(" xref from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
ptrs = find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data"))
for pa in ptrs[:8]:
print(" ptr-row @%#x next-q=%#x prev-q=%#x" %
(pa, qword(pa + 8), qword(pa - 8)))
for frm, typ, fn, ent in xrefs_to(pa):
print(" row xref %#x %s in %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""Q20: the functions that test cardtype==7 / cardsubtypeid in {9,10,11}.
Q19 (control passed: it recovered FUN_18011e3c0's 0x91/0x94/0x96 on a +0x50 field)
flagged:
FUN_1800f6c40 direct [+0x4c]==7 AND [+0x50]==9
FUN_180084720 direct [+0x50]==9 and [+0x50]==0xb
FUN_180094580 near [+0x50] 9 / 0xb / 3
FUN_18015fa80 direct [+0x50]==9
FUN_1801362e0 direct [+0x4c] 1 / 2 / 7
Decompile each. Whatever these do with subtypes 9/10/11 is the club-family
behaviour, and a loc key or asset id in any arm names the family.
CONTROL: FUN_1801362e0 is one of the merge's arms (called from FUN_180141660 case 2,
the manager arm) so it must be a DB lookup on carddbid; if it is not, the +0x4c
attribution is on a different struct and these hits are noise.
"""
import traceback
try:
for a in (0x1800F6C40, 0x180084720, 0x180094580, 0x18015FA80, 0x1801362E0):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 12000 else src[:12000] + "\n...[cut, len above]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,47 @@
"""Q21: the FUT data-manager vtable slots that name a club item.
FUN_1800f6c40 (the pack/award tile builder) does:
if (item+0x4c == 1) -> ITEM_RARITY / ITEM_LEVEL
else if (item+0x50 == 9) -> "IS_KIT_%d" = 1 <-- names subtype 9
name = mgr->vt[0x490](out, item+0x4c cardtype, item+0x50 subtype, item+0x18)
if (name empty && item+0x4c == 7)
name = mgr->vt[0x498](out, item+0x50 subtype, item+0x94 teamid, item+0x20)
where mgr = FUN_18011a830(). Slots 0x490 and 0x498 are therefore the club-item name
resolvers and must switch on the subtype.
Resolve the manager's vtable, then decompile slots 0x490, 0x498, 0xa08, 0xa38, 0xa40.
CONTROL: slot 0xa08 is the one the item deserializer calls to file a parsed item
(FUN_18013fe00 line 825), and slot 0xa40 is the lookup FUN_18011e3c0 uses with a
resourceId. If the resolved vtable's 0xa08/0xa40 are not functions, the vtable
resolution is wrong.
"""
import traceback
try:
src = dec(0x18011A830)
print("=== FUN_18011a830 (manager accessor) len=%d ===" % len(src))
print(src)
# find the vtable it installs / the object's class
print()
print("=== candidate vtables referenced from FUN_18011a830 and its callees ===")
f = func(0x18011A830)
cands = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x1802891FF:
cands.add(t)
for t in sorted(cands):
try:
v0, v1 = qword(t), qword(t + 8)
except Exception:
continue
print(" %#x -> %#x %#x (%s / %s)" %
(t, v0, v1, fname(v0) if 0x180000000 <= v0 < 0x181000000 else "-",
fname(v1) if 0x180000000 <= v1 < 0x181000000 else "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Q22: resolve the manager object's vtable through the global DAT_1802e6398.
FUN_18011a830 just returns DAT_1802e6398, so the vtable is installed wherever that
global is written. Find the writers, decompile the smallest, and read the vtable it
stores. Then dump slots 0x490 / 0x498 / 0xa08 / 0xa38 / 0xa40.
CONTROL: the recovered vtable's slot 0xa08 and 0xa40 must both be real functions
(the item deserializer calls 0xa08 to file an item; FUN_18011e3c0 calls 0xa40 with a
resourceId). If either is not a function, the vtable is wrong.
"""
import traceback
try:
print("=== writers/readers of DAT_1802e6398 ===")
ents = {}
for frm, typ, fn, ent in xrefs_to(0x1802E6398):
ents.setdefault(ent, []).append((frm, typ, fn))
for ent, lst in sorted(ents.items()):
print(" %s(%#x) n=%d types=%s" %
(lst[0][2], ent, len(lst), sorted({t for _f, t, _n in lst})))
# the constructor is a function that WRITES it
writers = [e for e, lst in ents.items()
if any(t == "WRITE" for _f, t, _n in lst)]
print("writers: %s" % ["%#x" % w for w in writers])
for w in writers:
src = dec(w)
print("=" * 70)
print("writer FUN_%x len=%d" % (w, len(src)))
print(src if len(src) < 6000 else src[:6000] + "\n...[cut]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,40 @@
"""Q23: callers of the manager setter FUN_18011d780 -> the manager's vtable.
CONTROL: the vtable found must have real functions at slots 0xa08 and 0xa40.
"""
import traceback
try:
for frm, typ, fn, ent in xrefs_to(0x18011D780):
print("caller %s(%#x) via %#x %s" % (fn, ent, frm, typ))
ents = {ent for _f, _t, _n, ent in xrefs_to(0x18011D780) if ent}
for ent in ents:
src = dec(ent)
print("=" * 70)
print("FUN_%x len=%d" % (ent, len(src)))
print(src if len(src) < 7000 else src[:7000] + "\n...[cut]")
f = func(ent)
cands = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x1802891FF:
cands.add(t)
print("--- .rdata refs, checked for vtable shape ---")
for t in sorted(cands):
try:
v0, v1 = qword(t), qword(t + 8)
s90, s98 = qword(t + 0x490), qword(t + 0x498)
a08, a40 = qword(t + 0xA08), qword(t + 0xA40)
except Exception:
continue
ok = all(fm.getFunctionAt(addr(x)) is not None
for x in (v0, v1) if 0x180000000 <= x < 0x181000000)
print(" %#x v0=%s v1=%s | +0x490=%s +0x498=%s +0xa08=%s +0xa40=%s%s" %
(t, fname(v0), fname(v1), fname(s90), fname(s98),
fname(a08), fname(a40), " <== VTABLE?" if ok else ""))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Q24: FUN_180119bd0 -- the cardtype-7 name resolver. This should BE the mapping.
The manager vtable was read out of the live process (read-only): DAT_1802e6398 ->
object -> vtable static 0x18021c2a0, with
+0x490 = FUN_18011a860 (cardtype switch 1,2,3,4,5,10 -- no club arm)
+0x498 = FUN_180119bd0 (called ONLY when +0x490 returned empty AND cardtype==7,
with args (cardsubtypeid, teamid, assetId))
+0xa08 = FUN_18011cca0 (file a parsed item)
+0xa38 = FUN_180113e40 (register trophy: (tournamentId, subtype, name))
+0xa40 = FUN_18011bf40 (lookup by resourceId)
Decompile 0x498, 0xa38, 0xa40 and 0xa08.
CONTROL: FUN_18011a860 must be the same function Q11 dumped (12905 chars) with the
cardtype switch; that is what makes the 0x498 fallback meaningful.
"""
import traceback
try:
for a, tag in [(0x180119BD0, "+0x498 club-item name resolver"),
(0x180113E40, "+0xa38 trophy register"),
(0x18011BF40, "+0xa40 lookup by resourceId"),
(0x18011CCA0, "+0xa08 file parsed item")]:
src = dec(a)
print("=" * 78)
print("%s FUN_%x len=%d" % (tag, a, len(src)))
print(src if len(src) < 14000 else src[:14000] + "\n...[cut, len above]")
print("=" * 78)
print("control: FUN_18011a860 len=%d" % len(dec(0x18011A860)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,53 @@
"""Q25: is there a cardtype-9 sibling of FUN_180119bd0 for balls and league logos?
FUN_180119bd0 settles cardtype 7: 9 -> "FUT_UC_KITS"+TeamName_Abbr15_<teamid>
10 -> "Stadium"+StadiumName_<assetId>
11 -> "Badge"+TeamName_Abbr15_<teamid>
That leaves 0x1e and 0x1f (the only other cardtype-9 subtypes besides trophies
0x91..0x96 and misc 0xe7..0xec) for ball and league logo.
Dump the loc-key string neighbourhood the resolver draws from (0x1801ec700..
0x1801ed400 holds 'Stadium'/'Ball' literals) with xrefs, and xref the exact literals
"Stadium", "Badge", "FUT_UC_KITS" to find any sibling resolver. A function that
references a ball or league-logo caption is the cardtype-9 equivalent.
CONTROL: the literals "Stadium" and "Badge" must show FUN_180119bd0 as an xref. If
they do not, I am looking at different copies of those strings.
"""
import traceback
try:
print("=== atoms 0xd1 and 0x19c (the two club-item wire strings) ===")
for a in (0xD1, 0x19C):
print(" %#x = %d" % (a, a))
print()
print("=== string dump 0x1801ec700..0x1801ed400 ===")
p = 0x1801EC700
while p < 0x1801ED400:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 120)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-46r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
print()
print("=== exact-literal xrefs ===")
for lit in (b"Stadium\x00", b"Badge\x00", b"FUT_UC_KITS\x00", b"Ball\x00",
b"BallName", b"LeagueLogo", b"leaguelogo", b"FUT_UC_"):
for h in find_all(lit):
s = rd_str(h, 80)
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(h)}))
print(" %-14r %#x %-30r <- %s" % (lit.rstrip(b"\x00").decode(), h, s, who or "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,39 @@
"""Q26: FUN_1801bfac0 -- the one function that names ALL the club families.
It references 'Stadium', 'Badge', 'FUT_UC_KITS' and 'FUT_UC_BALL' (and the GK
attribute captions), and Q10 showed it queries fcc_matches. If it switches on
cardsubtypeid it will name the ball subtype, which FUN_180119bd0 (cardtype 7 only)
cannot.
Also dump the string cluster 0x180239000..0x180239180 which holds FUT_UC_BALL,
'Stadium' and 'badge' close together, with xrefs.
CONTROL: FUN_180119bd0 must appear as an xref of 'Stadium' 0x18021ce80 and 'Badge'
0x1802041b8 -- it did in Q25, so the literal identification is sound.
"""
import traceback
try:
src = dec(0x1801BFAC0)
print("=== FUN_1801bfac0 len=%d ===" % len(src))
print(src if len(src) < 20000 else src[:20000] + "\n...[cut, len above]")
print()
print("=== strings 0x180238f80..0x180239200 ===")
p = 0x180238F80
while p < 0x180239200:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 120)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-40r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
except Exception:
traceback.print_exc()
@@ -0,0 +1,28 @@
"""Q27: dump FUN_1801bfac0 in FULL to disk (it is >20k chars and was cut in Q26).
It is the card-detail builder and the only function referencing FUT_UC_KITS,
'Stadium', 'Badge' AND FUT_UC_BALL, so its club arms should name every family
including the ball subtype that FUN_180119bd0 (cardtype 7 only) cannot reach.
Also dump FUN_180094580 (references FUT_UC_KITS, and Q19 flagged it testing
item+0x50 against 9 and 0xb) and FUN_180099490 ('Badge').
No truncation: written to files, lengths printed here.
"""
import os
import traceback
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
try:
os.makedirs(OUT, exist_ok=True)
for a in (0x1801BFAC0, 0x180094580, 0x180099490, 0x18015FA80, 0x180102790,
0x1800F6C40, 0x180084720):
src = dec(a)
p = os.path.join(OUT, "FUN_%x.c" % a)
with open(p, "w") as f:
f.write(src)
print(" FUN_%x len=%d" % (a, len(src)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""Q28: verify the accessor that FUN_1801bfac0 switches on IS cardsubtypeid, and
read the deserializer arms for the club-item string fields.
FUN_1801bfac0 does `iVar5 = FUN_1801a8640(local_78)` and then
iVar5 == 9 -> FUT_UC_KITS (+ FUT_ThirdKit / KitBioAwayDescription variants)
iVar5 == 10 -> "Stadium" + StadiumName_%d + StadiumDetailDesc
iVar5 == 0xb-> "Badge" + TeamName_Abbr15_%d + badgeBioDescription
iVar5 == 0x1e -> "FUT_UC_BALL"
iVar5 == 0x1f -> league-derived id, no generic asset string
iVar5 - 0xe7U < 2 / 0xe9 / 0xec -> misc
That reading only holds if FUN_1801a8640 returns the item's +0x50 cardsubtypeid.
Decompile it and its neighbours FUN_1801a8570 / FUN_1801a8560 / FUN_1801a8020 /
FUN_1801a86a0 / FUN_1801a8800 / FUN_1801a87f0 / FUN_1801a8040.
CONTROL: FUN_1801a86a0 is used in the badge arm as the argument to
TeamName_Abbr15_%d, so it must return the +0x94 teamid. If it returns something
else, my field-offset map for these accessors is wrong.
"""
import traceback
try:
for a in (0x1801A8640, 0x1801A8570, 0x1801A8560, 0x1801A8020, 0x1801A86A0,
0x1801A8800, 0x1801A87F0, 0x1801A8040):
src = dec(a)
print("-" * 70)
print("FUN_%x len=%d" % (a, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,61 @@
"""Q3: walk the whole enum-table block around 0x180229a00..0x180229e00.
Q2 found row @0x180229b50 = {'badge', 0xa}, 0x180229b60 = {'kit', 0xb},
0x180229b70 = {'leagueLogo', 0xc}, and 0x180229c10 = {'ball', 0x16} -- i.e. a
{name -> numeric code} table that NAMES THE CLUB FAMILIES. That is exactly the
mapping the brief asks for, IF the codes are cardsubtypeids.
HYPOTHESIS: one of these tables is the cardsubtypeid vocabulary. Codes 0xa/0xb/0xc
are NOT in the cardtype-9 subtype set (0x1e,0x1f,0x91..0x96), so either it is a
different axis (an "item sub-family" enum) or the mapping is indirect.
CONTROL: the itemState table at 0x180229cc0 (invalid/free/WAITING_FOR_GAME/...)
must reappear intact inside the same walk, with the same codes Q2 printed.
Dump every 0x10 row from 0x180229800 to 0x180229f00, printing ptr, string, value.
Then xref every table start candidate (a row whose predecessor is not a valid
string row) to find the lookup function.
"""
import traceback
try:
LO, HI = 0x180229800, 0x180229F00
rows = []
a = LO
while a < HI:
try:
q0, q1 = qword(a), qword(a + 8)
except Exception:
a += 0x10
continue
s = None
if 0x180000000 <= q0 < 0x181000000:
try:
t = rd_str(q0, 64)
if t and all(0x20 <= ord(c) < 0x7F for c in t):
s = t
except Exception:
pass
rows.append((a, q0, s, q1))
a += 0x10
print("=== enum row walk %#x..%#x ===" % (LO, HI))
prev_ok = False
starts = []
for (a, q0, s, q1) in rows:
mark = ""
ok = s is not None
if ok and not prev_ok:
mark = " <== TABLE START?"
starts.append(a)
prev_ok = ok
print(" %#x ptr=%#018x %-28r val=%#-10x%s" % (a, q0, s or "", q1, mark))
print()
print("=== xrefs to each candidate table start ===")
for a in starts:
print(" start %#x" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,46 @@
"""Q4: the enum converter helpers and their callers.
Q3 established two request-side vocabularies:
table 0x180229ab0 "subtype filter": any=-1 playerGK=1..physio=9 badge=0xa kit=0xb
leagueLogo=0xc playerTraining=0xd GKTraining=0xe position=0xf playStyle=0x10
managerLeagueModifier=0x11 contract=0x12 fitness=0x13 healing=0x14
stadium=0x15 ball=0x16
table 0x180229c30 "type filter": any=-1 player=1 staff=2 clubInfo=3 training=4
development=5 stadium=6 ball=7
table 0x180229cc0 "itemState": invalid=0 free=1 WAITING_FOR_GAME=2 inGame=2
forSale=5 offered=6 activeBadge=0x64 .. activeStadium=0x68 active=0xff
HYPOTHESIS: the converter functions FUN_180166300 (subtype), FUN_180166340 (type),
FUN_180166660 (itemState) are string<->code helpers; their CALLERS are the request
builder and the equip path. The equip path must choose 0x64..0x68 from the item, and
that choice is the subtype->family mapping we want.
CONTROL: FUN_1800d8330, decompiled in full here, must reproduce the documented
cardtype-9 subtype set {0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}. If it does not, my
project copy is not the analysed one.
"""
import traceback
try:
for a, tag in [(0x1800D8330, "CONTROL FUN_1800d8330 cardsubtype->cardtype"),
(0x180166300, "subtype-enum helper"),
(0x180166340, "type-enum helper"),
(0x180166660, "itemState helper A"),
(0x1801666F0, "itemState/other helper B"),
(0x180166790, "helper C")]:
src = dec(a)
print("=" * 78)
print("%s @%#x len=%d" % (tag, a, len(src)))
print(src)
print("=" * 78)
print("=== callers ===")
for a in (0x180166300, 0x180166340, 0x180166660, 0x1801666F0, 0x180166790):
print(" callers of %#x:" % a)
seen = set()
for frm, typ, fn, ent in xrefs_to(a):
if ent in seen:
continue
seen.add(ent)
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
except Exception:
traceback.print_exc()
@@ -0,0 +1,69 @@
"""Q5: who consumes cardtype 9 / the club subtypes?
Two prongs.
A. FUN_180162c90 calls BOTH code->string helpers (type at 0x180162cec, subtype at
0x180163071), so it is the request builder for club?type=...&... Decompile it in
full: it names the query parameters and shows which enum feeds which parameter.
This answers Q3 directly.
B. THE ABSENCE TRAP GUARD. Rather than grep for "== 0x91", scan EVERY instruction in
.text for a scalar operand in the club-subtype set {0x1e,0x1f,0x91..0x96} and
group by containing function, regardless of mnemonic (cmp / sub / mov / lea /
switch-index arithmetic all count), then report functions that carry three or more
DISTINCT members of the set. A dispatch written as a sub-ladder or a jump table
still shows up because the constants themselves must exist somewhere -- and if a
jump table is used, the case labels live in .rdata, so also scan .rdata/.data for
the byte-pattern of a switch index table.
CONTROL for the scan: FUN_1800d8330 is known to contain all eight of 0x1e,0x1f,
0x91..0x96 as switch case labels. If the scan does not list FUN_1800d8330, the scan
is broken and any "no other consumer" conclusion is void.
"""
import traceback
try:
src = dec(0x180162C90)
print("=== A: FUN_180162c90 request builder, len=%d ===" % len(src))
print(src)
print()
print("=== B: scalar scan over .text for {0x1e,0x1f,0x91..0x96} ===")
TARGET = {0x1E, 0x1F, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
per = {}
block = None
for b in mem.getBlocks():
if b.getName() == ".text":
block = b
break
it = listing.getInstructions(block.getStart(), True)
n = 0
while it.hasNext():
ins = it.next()
if ins.getAddress().getOffset() > block.getEnd().getOffset():
break
n += 1
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if v in TARGET:
f = fm.getFunctionContaining(ins.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
per.setdefault(key, {}).setdefault(v, []).append(
int(ins.getAddress().getOffset()))
print(" instructions scanned: %d" % n)
rank = sorted(per.items(), key=lambda kv: -len(kv[1]))
for (name, ent), d in rank:
if len(d) < 3:
continue
print(" %-28s %#x distinct=%d %s" %
(name, ent, len(d), sorted("%#x" % k for k in d)))
print(" --- control present? FUN_1800d8330 ---")
for (name, ent), d in per.items():
if ent == 0x1800D8330:
print(" YES: distinct=%d %s" % (len(d), sorted("%#x" % k for k in d)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,48 @@
"""Q6: the club query taxonomy switch and the club URL builder.
Prior work (tools/fut_club_stats.py header) records FUN_18012ec50 as the 30-arm
atom switch that turns a club ?type= name into a code, with 11 badge, 12 kit,
13 stadium, 14 ball, 15 equippables, 16 leaguelogos.
HYPOTHESIS: something downstream of that code either (a) adds a &cardsubtype=
filter, which would give the mapping outright, or (b) buckets the RESPONSE items
by cardsubtypeid for the combined equippables view.
NOTE ON THE FAILED CONTROL IN Q5: the scalar scan did NOT find FUN_1800d8330,
whose 0x1e/0x1f/0x91..0x96 case labels are a jump table, so scalar scanning cannot
see switch dispatch here. Every "not found" from that scan is void. This query
therefore works from call graph and strings only.
CONTROL here: FUN_18012ec50 must decompile to a switch whose arms match the 30
names already recorded. If it does not, the recorded note (and my starting point)
is wrong.
"""
import traceback
try:
for a, tag in [(0x18012EC50, "CONTROL club ?type= taxonomy switch"),
(0x180096670, "UI group-table switch")]:
src = dec(a)
print("=" * 78)
print("%s @%#x len=%d" % (tag, a, len(src)))
print(src)
print("=" * 78)
print("=== callers of FUN_18012ec50 ===")
for frm, typ, fn, ent in xrefs_to(0x18012EC50):
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
print()
print("=== url-ish literals ===")
for p in [b"/club?", b"/club", b"club?type", b"type=%s", b"&cat=%s",
b"&cardsubtype=%d", b"&count=%d", b"/item", b"/purchased"]:
hits = find_all(p)
print(" %-18r n=%d" % (p.decode(), len(hits)))
for h in hits[:14]:
s = rd_str(h, 120)
fs = []
for frm, typ, fn, ent in xrefs_to(h):
fs.append("%s(%#x)" % (fn, ent))
print(" %#x %-58r <- %s" % (h, s, ",".join(fs) or "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""Q7: every caller of the cardtype classifier, and how each treats cardtype 9.
FUN_1800d8330(cardsubtypeid) -> cardtype. Anything that needs to know "this is a
club item" must either call it or test cardsubtypeid directly. Enumerate its
callers, decompile each, and print EVERY line mentioning a 9 in any comparison or
switch form (case 9:, ==9, !=9, -9, <9, >9), so an arm written as != does not hide.
Also decompile the shared item deserializer FUN_18013fe00 fully? No: too long for
one batch. Instead print its length and the lines around the FUN_1800d8330 call and
around the itemState helper call at 0x1801406e3.
CONTROL: FUN_1800d8330 itself is excluded; the caller list must be non-empty and
must include the item deserializer FUN_18013fe00 (which is documented to compute
the family into item+0x4c). If FUN_18013fe00 is absent from the caller list, the
xref enumeration is broken.
"""
import re
import traceback
try:
seen = {}
for frm, typ, fn, ent in xrefs_to(0x1800D8330):
if ent and ent != 0x1800D8330:
seen.setdefault(ent, fn)
print("=== callers of FUN_1800d8330: %d ===" % len(seen))
pat = re.compile(r"(case\s+9\s*:|[=!<>]=\s*9\b|==\s*9\b|!=\s*9\b|\b9\s*[=!<>]|-\s*9\b)")
for ent, fn in sorted(seen.items()):
src = dec(ent)
lines = src.splitlines()
hits = [(i, l.strip()) for i, l in enumerate(lines) if pat.search(l)]
print("-" * 70)
print("%s @%#x len=%d nine-lines=%d" % (fn, ent, len(src), len(hits)))
for i, l in hits[:40]:
print(" %4d: %s" % (i, l))
print("=" * 70)
src = dec(0x18013FE00)
print("item deser FUN_18013fe00 len=%d" % len(src))
for i, l in enumerate(src.splitlines()):
if "1800d8330" in l or "180166660" in l or "1801666f0" in l:
print(" %4d: %s" % (i, l.strip()))
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""Q8: the cardtype-9 arms inside the shared item deserializer.
Q7: FUN_1800d8330 has exactly ONE caller, FUN_18013fe00, and the decompile has
`if (local_13c == 9)` at line 729 and `if ((int)local_138 == 9)` at line 754, where
local_138 is the cardsubtypeid fed to the classifier and local_13c is the cardtype.
Print lines 640..900 of that decompile verbatim (no truncation claim will be made
from a window: the window is stated as a window). Also print lines 380..470 which
contain the itemState conversion calls, and the function tail (last 80 lines),
because the brief warns that locals that look dead are often copied out at the tail.
"""
import traceback
try:
src = dec(0x18013FE00)
lines = src.splitlines()
print("total lines=%d chars=%d" % (len(lines), len(src)))
for lo, hi, tag in [(380, 470, "itemState region"),
(640, 900, "cardtype-9 region"),
(len(lines) - 90, len(lines), "tail")]:
print("=" * 70)
print("--- %s: lines %d..%d ---" % (tag, lo, hi))
for i in range(max(0, lo), min(len(lines), hi)):
print("%5d: %s" % (i, lines[i]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,32 @@
"""Q9: the five functions that carry club-subtype immediates individually.
Q5's scalar scan cannot see jump-table switches (its control, FUN_1800d8330, was
missed), but everything it DID find is a real immediate in an instruction. Those
functions are:
FUN_1801017e0 0x1e 0x91 0x92 0x93 0x94
FUN_180108c00 0x1e 0x91 0x94 0x96
FUN_18011e3c0 0x91 0x94 0x96
FUN_18011e9d0 0x91 0x94 0x96
FUN_18013af30 0x1e 0x1f 0x94
FUN_180067d00 0x94 0x95 0x96
Plus the range test found in Q8 inside the item deserializer at line 738:
(int)cardsubtypeid - 0x91U < 4 -> {0x91,0x92,0x93,0x94} take a custom-image path.
Decompile all six in full. Anything that ties one of these constants to a loc key,
an asset path or a UI slot is the mapping.
CONTROL: FUN_18013af30 must contain 0x1e/0x1f/0x94 somewhere in its text; if a
decompile comes back without the constants the scan attributed to it, the scan's
function attribution is wrong.
"""
import traceback
try:
for a in (0x1801017E0, 0x180108C00, 0x18011E3C0, 0x18011E9D0, 0x18013AF30,
0x180067D00):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,95 @@
"""Q1 scaffolding: locate the fcc_discardcoins query site and its containing
function(s), and print the raw instruction stream around the three bind sites.
HYPOTHESIS: the SQL at 0x1802231e4.. is built and bound inside one function whose
frame holds the item pointer; cardtype/level/rare come from three distinct item
struct offsets, and the result is stored at item+0x3c.
CONTROL: 0x18013fe00 (the shared ITEM deserializer, known function) and
0x1800d8330 (the known cardsubtypeid->cardtype mapper) must both resolve to real
functions with sane sizes. If they do not, the project copy is wrong and every
other answer here is void.
Absence discipline: nothing in this file claims absence. It only prints.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q1_out.txt"
try:
lines = []
def P(*a):
s = " ".join(str(x) for x in a)
lines.append(s)
LITS = {
0x1802231E4: "sql frag A",
0x1802231F0: "sql frag B",
0x1802231F4: "fcc_discardcoins",
0x180223208: "sql frag C",
0x180207848: "sql frag D",
0x18022315C: "sql frag E",
}
P("=== LITERALS ===")
for a, tag in sorted(LITS.items()):
try:
P("%#x %-18s %r" % (a, tag, rd_str(a, 120)))
except Exception as e:
P("%#x %-18s READ FAIL %s" % (a, tag, e))
P("")
P("=== XREFS TO LITERALS ===")
for a, tag in sorted(LITS.items()):
xs = xrefs_to(a)
P("%#x %s -> %d refs" % (a, tag, len(xs)))
for frm, typ, fn, ent in xs:
P(" from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent))
P("")
P("=== FUNCTION IDENTITY ===")
for a in (0x18013FE00, 0x1800D8330, 0x180141025, 0x180141119, 0x180141140,
0x180141660, 0x180141E8A, 0x180140F00):
f = func(a)
if f is None:
P("%#x -> NO FUNCTION" % a)
continue
b = f.getBody()
P("%#x -> %s entry=%#x body=[%#x..%#x] size=%d"
% (a, f.getName(), int(f.getEntryPoint().getOffset()),
int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset()),
int(b.getNumAddresses())))
P("")
P("=== RAW INSTRUCTIONS 0x180140f80 .. 0x180141200 ===")
p = 0x180140F80
while p < 0x180141200:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P("%#x <no instruction>" % p)
p += 1
continue
P("%#x %s" % (p, ins))
p += ins.getLength()
P("")
P("=== RAW INSTRUCTIONS 0x180141e40 .. 0x180141f00 (level derivation) ===")
p = 0x180141E40
while p < 0x180141F00:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P("%#x <no instruction>" % p)
p += 1
continue
P("%#x %s" % (p, ins))
p += ins.getLength()
src = dec(0x180141025)
P("")
P("=== FULL DECOMPILE of function containing 0x180141025, len=%d ===" % len(src))
P(src)
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,134 @@
"""Q: the client's fcc_discardcoins lookup does NOT miss (proved live). So why did
every card render "Quick Sell 0" when we omitted discardValue?
HYPOTHESIS H1: the consumer of the price reads item+0x38 (the wire discardValue)
only, and NEVER item+0x3c (where the client's own computation lands). That would
explain both halves of the observation: 0 with the field omitted, exact with it
sent.
RIVAL H2: the consumer reads +0x38 if non-zero else +0x3c. H2 predicts the
omitted-field test should have shown the right numbers, which it did not, but H2
survives if the failing observation was mis-made, so decide it on code.
METHOD: enumerate EVERY instruction in .text whose memory operand is a dword at
displacement 0x38 or 0x3c, tabulate by containing function, and then look at the
functions that touch BOTH (a candidate "sent else computed" selector) versus
functions that touch only one.
CONTROL: FUN_18013fe00 must appear in both tables (it provably reads +0x38 at
0x180141025 and writes +0x3c at 0x180141140). If the scan does not find those two
exact addresses, the scan is broken and its silence means nothing.
Also: FUN_180141660 in full, plus every string literal it references, for the
"does the miss degrade other screens" question.
Absence discipline: this scan enumerates operands, not immediates, so it is
immune to the ==/!=/switch/ladder trap; but it is scoped to CardsDLL only and
says nothing about FIFA17.exe.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q2_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
# ---------------------------------------------------------------- 1. operand scan
from ghidra.program.model.lang import OperandType
hits38, hits3c = {}, {}
n_ins = 0
it = listing.getInstructions(True)
while it.hasNext():
ins = it.next()
n_ins += 1
txt = str(ins)
if "0x38]" not in txt and "0x3c]" not in txt:
continue
a = int(ins.getAddress().getOffset())
f = fm.getFunctionContaining(ins.getAddress())
key = (f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0)
if "0x38]" in txt:
hits38.setdefault(key, []).append((a, txt))
if "0x3c]" in txt:
hits3c.setdefault(key, []).append((a, txt))
P("instructions scanned: %d" % n_ins)
P("functions touching a +0x38 operand: %d" % len(hits38))
P("functions touching a +0x3c operand: %d" % len(hits3c))
P("")
P("=== CONTROL ===")
ctl = [t for k, v in hits38.items() for t in v if t[0] == 0x180141025]
P("read of +0x38 at 0x180141025 found: %s" % (ctl or "NO -- SCAN BROKEN"))
ctl2 = [t for k, v in hits3c.items() for t in v if t[0] == 0x180141140]
P("write of +0x3c at 0x180141140 found: %s" % (ctl2 or "NO -- SCAN BROKEN"))
both = sorted(set(hits38) & set(hits3c), key=lambda k: k[1])
P("")
P("=== FUNCTIONS TOUCHING BOTH +0x38 AND +0x3c (%d) ===" % len(both))
for k in both:
P(" %s @ %#x" % k)
for a, t in sorted(hits38[k]):
P(" 38 %#x %s" % (a, t))
for a, t in sorted(hits3c[k]):
P(" 3c %#x %s" % (a, t))
only3c = sorted(set(hits3c) - set(hits38), key=lambda k: k[1])
P("")
P("=== FUNCTIONS TOUCHING +0x3c ONLY (%d) ===" % len(only3c))
for k in only3c:
P(" %s @ %#x" % k)
for a, t in sorted(hits3c[k]):
P(" %#x %s" % (a, t))
# ---------------------------------------------------------------- 2. tiny accessors
P("")
P("=== TINY ACCESSOR BYTE PATTERNS IN .text ===")
pats = {
"mov eax,[rcx+0x38]; ret": b"\x8b\x41\x38\xc3",
"mov eax,[rcx+0x3c]; ret": b"\x8b\x41\x3c\xc3",
"mov eax,[rcx+0x38]": b"\x8b\x41\x38",
"mov eax,[rcx+0x3c]": b"\x8b\x41\x3c",
}
for name, pat in pats.items():
hs = find_all(pat, blocks=(".text",))
P(" %-26s %d hits" % (name, len(hs)))
for h in hs[:40]:
f = fm.getFunctionContaining(addr(h))
P(" %#x in %s" % (h, f.getName() if f else "?"))
# ---------------------------------------------------------------- 3. FUN_180141660
src = dec(0x180141660)
P("")
P("=== FUN_180141660 FULL DECOMPILE, len=%d ===" % len(src))
P(src)
P("")
P("=== STRING LITERALS REFERENCED BY FUN_180141660 ===")
f = func(0x180141660)
seen = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if t in seen or not (0x1801E5000 <= t <= 0x180290000):
continue
seen.add(t)
try:
s = rd_str(t, 80)
except Exception:
continue
if s and all(32 <= ord(c) < 127 for c in s) and len(s) >= 3:
P(" %#x from %#x %r" % (t, int(ad.getOffset()), s))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,132 @@
"""Q: who READS the parsed item record's discard fields, item+0x38 (the wire
discardValue) and item+0x3c (the client's own fcc_discardcoins result)?
WHY q2's CONTROL FAILED, and why that was the control's fault: inside
FUN_18013fe00 the item is a STACK STRUCT at RBP+0x160, so the guard reads
[RBP+0x198] and the store writes [RBP+0x19c]. A scan for the displacements 0x38
and 0x3c can never see them. The control was invalid, not the scan.
NEW METHOD -- FINGERPRINT THE STRUCT, NOT THE OFFSET. The item record has several
displacements that are rare in general code: +0x146 (preferredPosition, u16),
+0x148 (nation), +0x154 (leagueId), +0x94 (teamid), +0xb4 (rating). Any function
that dereferences a pointer at two or more of those is handling an item record.
Collect the displacement set per function from the instruction text, select the
item handlers, and then report their +0x38 / +0x3c usage.
SECOND TEST, independent of the fingerprint: find every place in .text where a
dword is READ at [reg+0x38] and, within 0x40 bytes and off the SAME base
register, a dword is READ at [reg+0x3c]. That is the shape of a "server value
else computed value" selector. H1 (consumer reads +0x38 only) predicts no such
selector on an item; H2 predicts one.
CONTROL for this run: the fingerprint must select FUN_18013fe00 itself when the
frame register RBP is allowed, because that function demonstrably touches
RBP+0x2a6 (0x146+0x160), RBP+0x2a8, RBP+0x2b4 and RBP+0x214. I print the
frame-relative fingerprint hits separately for exactly that reason.
"""
import re
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q3_out.txt"
RE_MEM = re.compile(r"\[(R[A-Z0-9]+) \+ (-?0x[0-9a-f]+)\]")
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
ITEM_MARKS = (0x146, 0x148, 0x154, 0x94, 0xb4, 0x8c)
FRAME_MARKS = tuple(m + 0x160 for m in ITEM_MARKS)
per_fn = {} # entry -> {'name', 'disp': {d: [(addr, base, txt)]}}
reads38 = [] # (addr, base, fnentry, txt)
reads3c = []
n_ins = 0
it = listing.getInstructions(True)
while it.hasNext():
ins = it.next()
n_ins += 1
txt = str(ins)
if "[" not in txt:
continue
ms = RE_MEM.findall(txt)
if not ms:
continue
a = int(ins.getAddress().getOffset())
f = fm.getFunctionContaining(ins.getAddress())
ent = int(f.getEntryPoint().getOffset()) if f else 0
rec = per_fn.setdefault(ent, {"name": f.getName() if f else "?", "disp": {}})
for base, dtxt in ms:
d = int(dtxt, 16)
rec["disp"].setdefault(d, []).append((a, base, txt))
if d == 0x38 and txt.startswith("MOV E") and "dword ptr [" + base in txt:
reads38.append((a, base, ent, txt))
if d == 0x3c and txt.startswith("MOV E") and "dword ptr [" + base in txt:
reads3c.append((a, base, ent, txt))
P("instructions scanned: %d ; functions with memory operands: %d" % (n_ins, len(per_fn)))
# ---- CONTROL: the frame-relative fingerprint must select FUN_18013fe00
P("")
P("=== CONTROL: frame-relative item fingerprint (marks + 0x160) ===")
ctl = []
for ent, rec in per_fn.items():
got = [m for m in FRAME_MARKS if m in rec["disp"]]
if len(got) >= 3:
ctl.append((ent, rec["name"], [hex(g) for g in got]))
for ent, nm, got in sorted(ctl):
P(" %-18s %#x marks %s %s" % (nm, ent, got,
"<== FUN_18013fe00" if ent == 0x18013FE00 else ""))
P(" control %s" % ("PASS" if any(e == 0x18013FE00 for e, _, _ in ctl)
else "FAIL -- fingerprint cannot see the known item handler"))
# ---- pointer-relative fingerprint: the real search
P("")
P("=== ITEM HANDLERS BY POINTER-RELATIVE FINGERPRINT (>=2 of %s) ==="
% [hex(m) for m in ITEM_MARKS])
cands = []
for ent, rec in per_fn.items():
got = []
for m in ITEM_MARKS:
for (a, base, txt) in rec["disp"].get(m, []):
if base not in ("RSP", "RBP"):
got.append(m)
break
if len(got) >= 2:
cands.append((ent, rec["name"], got))
P("candidates: %d" % len(cands))
for ent, nm, got in sorted(cands):
rec = per_fn[ent]
h38 = [(a, b, t) for (a, b, t) in rec["disp"].get(0x38, []) if b not in ("RSP", "RBP")]
h3c = [(a, b, t) for (a, b, t) in rec["disp"].get(0x3c, []) if b not in ("RSP", "RBP")]
P("")
P(" %-18s %#x marks %s +0x38:%d +0x3c:%d"
% (nm, ent, [hex(g) for g in got], len(h38), len(h3c)))
for a, b, t in h38:
P(" 38 %#x %s" % (a, t))
for a, b, t in h3c:
P(" 3c %#x %s" % (a, t))
# ---- selector shape
P("")
P("=== SELECTOR SHAPE: dword read [reg+0x38] then dword read [SAME reg+0x3c] within 0x40 ===")
idx3c = {}
for a, base, ent, txt in reads3c:
idx3c.setdefault(base, []).append((a, ent, txt))
nsel = 0
for a, base, ent, txt in reads38:
for a2, ent2, txt2 in idx3c.get(base, []):
if 0 < a2 - a <= 0x40:
nsel += 1
nm = per_fn.get(ent, {}).get("name", "?")
P(" %s @ %#x : %#x %s -> %#x %s" % (nm, ent, a, txt, a2, txt2))
P(" selectors found: %d" % nsel)
P(" (reads at +0x38: %d, reads at +0x3c: %d, over the whole .text)"
% (len(reads38), len(reads3c)))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,51 @@
"""Q: identify the CONSUMER of the item record's discard fields.
The parsed stack struct is local_188 (RBP+0x160) and it is handed to
`FUN_18011a830()->vtbl[0xa08](mgr, existingItem, &parsed)`. The heap records our
live probe walked have the SAME layout (discardValue at +0x38, client-computed at
+0x3c, both confirmed live), so a consumer reads [reg+0x38] / [reg+0x3c].
H1: no consumer reads +0x3c off an item; the display reads +0x38 only.
H2: some consumer reads +0x38 and falls back to +0x3c.
q3 found 40 "read 0x38 then read 0x3c off the same base" sites DLL-wide, three of
which sit in functions carrying an item fingerprint. Decompile those and decide.
CONTROL: FUN_18013fe00 is a known item handler and FUN_1800d8330 a known
cardsubtypeid mapper; both must decompile to something recognisable. Also resolve
FUN_18011a830's vtable slot 0xa08, which is the known sink, as a positive check
that vtable resolution works here at all.
Absence discipline: any "no consumer reads +0x3c" statement below is scoped to
CardsDLL's .text only. FIFA17.exe is Denuvo-packed and is NOT searched, so a
consumer living there cannot be excluded by this query.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q4_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
P("=== SINK: FUN_18011a830 and vtable slot 0xa08 ===")
src = dec(0x18011A830)
P("FUN_18011a830 decompile, len=%d" % len(src))
P(src)
for tgt in (0x1800AA440, 0x18007C5F0, 0x18007BF00, 0x1800D7920):
s = dec(tgt)
P("")
P("=" * 100)
P("=== FUN_%x FULL DECOMPILE, len=%d ===" % (tgt, len(s)))
P(s)
P("--- callers of %#x ---" % tgt)
for frm, typ, fn, ent in xrefs_to(tgt):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,85 @@
"""Q: which value does the UI actually receive for the quick-sell price?
LEAD: the UI layer is bound by NAME. q4 showed calls of the shape
(**(code **)(*view + 0x48))(view, "LATEST_GAMES_WON", value)
so every number the client renders is pushed through a named setter. If the
quick-sell price is pushed under a name, finding that name finds the reader, and
the reader tells us whether it reads item+0x38 (the wire discardValue) or
item+0x3c (the client's own fcc_discardcoins result).
METHOD: enumerate every printable string in .rdata/.data whose text contains
DISCARD / discard / QUICK / Quick / SELL / Sell / COIN / Coin, print it with its
xrefs and the containing function, then decompile the functions that push a
discard-ish name.
CONTROL: the search must find the strings we already know exist -- the SQL
fragments "fcc_discardcoins" (0x1802231f0) and "price" (0x1802231e4) both
contain the target substrings, and the known UI name "LATEST_GAMES_WON" must
show up under COIN?? no -- it must show up in a separate positive check that the
string enumerator sees UI names at all. Both checks are printed explicitly. If
either fails the enumerator is broken and its silence means nothing.
"""
import re
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q5_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
NEEDLES = ("DISCARD", "discard", "Discard", "QUICK", "Quick", "quick",
"SELL", "Sell", "sell", "COIN", "Coin", "coin")
# gather printable C strings out of .rdata/.data
blocks = {}
for b in mem.getBlocks():
if b.getName() in (".rdata", ".data") and b.isInitialized():
s = int(b.getStart().getOffset())
n = int(b.getEnd().getOffset()) - s + 1
blocks[b.getName()] = (s, n)
P("blocks: %s" % {k: (hex(v[0]), v[1]) for k, v in blocks.items()})
RE_STR = re.compile(rb"[ -~]{4,120}\x00")
found = []
for bn, (s, n) in blocks.items():
off = 0
CH = 1 << 20
while off < n:
ln = min(CH, n - off)
data = read_bytes(s + off, ln)
for m in RE_STR.finditer(data):
txt = m.group()[:-1].decode("ascii")
if any(x in txt for x in NEEDLES):
found.append((s + off + m.start(), bn, txt))
off += ln - 130 if ln == CH else ln
# dedupe
seen = set()
found = [f for f in found if not (f[0] in seen or seen.add(f[0]))]
P("strings matching %s: %d" % (list(NEEDLES), len(found)))
P("")
P("=== CONTROL 1: the two known SQL literals must be in the hit list ===")
hits = {a for a, _, _ in found}
P(" 0x1802231f0 'fcc_discardcoins' present: %s" % (0x1802231F0 in hits))
P(" 0x1802231e4 'price' present (should be False, no needle): %s" % (0x1802231E4 in hits))
P("")
P("=== CONTROL 2: the enumerator sees UI names -- LATEST_GAMES_WON ===")
la = find_all(b"LATEST_GAMES_WON\x00", blocks=(".rdata", ".data"))
P(" LATEST_GAMES_WON found at %s" % [hex(x) for x in la])
P("")
P("=== HITS WITH XREFS ===")
for a, bn, txt in sorted(found):
xs = xrefs_to(a)
fns = sorted({(fn, ent) for _, _, fn, ent in xs if ent})
P("%#x [%s] %-46r xrefs=%d %s"
% (a, bn, txt, len(xs), ", ".join("%s@%#x" % f for f in fns[:8])))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,80 @@
"""Q: FUN_1800eb850 pushes BOTH "DISCARD_CREDITS" (3 refs) and
"CALCULATED_DISCARD_CREDITS" (1 ref) to the UI. Which item offset feeds each?
H1 predicts DISCARD_CREDITS <- item+0x38 (the wire discardValue) and
CALCULATED_DISCARD_CREDITS <- item+0x3c (the client's fcc_discardcoins result),
two independent UI properties with no native fallback between them. If so, which
one the card tile shows is decided in the Flash/Scaleform asset, not in native
code, and the "Quick Sell 0" observation means the tile binds DISCARD_CREDITS.
METHOD: full decompile plus the raw instruction stream around every reference to
the two names, so the register feeding the third argument is visible rather than
inferred from decompiler variable naming. Also the callers of FUN_1800eb850, and
the same treatment for the sibling names DISCARD / COINS_AWARDED.
CONTROL: FUN_18013fe00's own two sites are the reference semantics: the guard
reads [RBP+0x198] (= item+0x38) and the store writes [RBP+0x19c] (= item+0x3c).
Any offsets this query reports must be interpretable against a struct base held
in a register; I print the full function so the base can be traced, rather than
quoting a fragment.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q6_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
for nm, a in (("DISCARD_CREDITS", 0x1802161C8),
("CALCULATED_DISCARD_CREDITS", 0x1802161D8),
("DISCARD", 0x1801F4D28)):
P("=== xrefs to %r (%#x) ===" % (nm, a))
for frm, typ, fn, ent in xrefs_to(a):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
P("")
f = func(0x1800EB850)
b = f.getBody()
lo = int(b.getMinAddress().getOffset())
hi = int(b.getMaxAddress().getOffset())
P("FUN_1800eb850 body [%#x..%#x] size=%d" % (lo, hi, int(b.getNumAddresses())))
src = dec(0x1800EB850)
P("")
P("=== FUN_1800eb850 FULL DECOMPILE, len=%d ===" % len(src))
P(src)
P("")
P("=== FUN_1800eb850 FULL DISASSEMBLY ===")
p = lo
while p <= hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P("%#x <none>" % p)
p += 1
continue
extra = ""
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x180290000:
try:
s = rd_str(t, 60)
except Exception:
s = ""
if s:
extra = " ; %r" % s
P("%#x %s%s" % (p, ins, extra))
p += ins.getLength()
P("")
P("=== CALLERS OF FUN_1800eb850 ===")
for frm, typ, fn, ent in xrefs_to(0x1800EB850):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,69 @@
"""Q: which struct offsets do the two UI getters read?
FUN_1800eb850 pushes:
"DISCARD_CREDITS" <- FUN_1801a8620(handle)
"CALCULATED_DISCARD_CREDITS" <- FUN_1801a8090(handle)
"CARD_LEVEL" <- FUN_1801a80c0(handle)
"CARD_RARITY" <- FUN_1801a8880 || FUN_1801a88c0
H1 predicts FUN_1801a8620 reads +0x38 and FUN_1801a8090 reads +0x3c, i.e. the
wire value and the client's own computation are exposed to the UI as two
SEPARATE named properties with no native fallback between them.
BONUS CONTROL, and it is a strong one: the live probe showed item+0x54 holding
1/2/3 exactly tracking rating (3 if >=75, 2 if >=65, else 1) on all 22 resident
records, which contradicts the existing field map's "itemType 3=player 2=staff".
If FUN_1801a80c0 ("CARD_LEVEL") reads +0x54, that independently settles it as
the fcc_discardcoins `level`.
Also print the callers of FUN_1800eb850 to name the screen.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q7_out.txt"
TARGETS = [0x1801A8620, 0x1801A8090, 0x1801A80C0, 0x1801A8880, 0x1801A88C0,
0x1801A8850, 0x1801A8810, 0x1801A7140, 0x1801A78F0]
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
for t in TARGETS:
f = func(t)
if f is None:
P("%#x NO FUNCTION" % t)
continue
b = f.getBody()
lo, hi = int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset())
P("=" * 92)
P("FUN_%x body [%#x..%#x] size=%d" % (t, lo, hi, int(b.getNumAddresses())))
P("--- disassembly (full) ---")
p = lo
while p <= hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P(" %#x <none>" % p)
p += 1
continue
P(" %#x %s" % (p, ins))
p += ins.getLength()
s = dec(t)
P("--- decompile, len=%d ---" % len(s))
P(s)
P("")
P("=" * 92)
P("=== CALLERS OF FUN_1800eb850 ===")
for frm, typ, fn, ent in xrefs_to(0x1800EB850):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
if ent:
for f2, t2, n2, e2 in xrefs_to(ent):
P(" <- %#x %s @ %#x" % (f2, n2, e2))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,89 @@
"""Final batch.
Q-A How many places consume each getter?
FUN_1801a8620 = item+0x38 (wire discardValue, UI name DISCARD_CREDITS)
FUN_1801a8090 = item+0x3c (client fcc_discardcoins result, UI name
CALCULATED_DISCARD_CREDITS)
If +0x3c has exactly ONE consumer and it is the UI property push, then no
native code ever falls back from the wire value to the computed one, and
which number the tile shows is a Flash-asset decision. That is H1.
Q-B What is FUN_1800eb850 registered as? Its only xrefs are DATA slots at
0x180216130 / 0x1802658a0 / 0x1802f85ec, so it sits in a dispatch table.
Dump the qwords either side and resolve any string pointers to name it.
Q-C item+0x34 feeds the UI property "BOUGHT_FOR" and is NOT on the field map.
Print the deserializer sites that write +0x194 (= 0x34 + 0x160, the frame
form inside FUN_18013fe00) so the wire atom can be identified later.
CONTROL: FUN_1801a8620 and FUN_1801a8090 are 8-byte leaf functions whose bodies
were printed in q7, so their identity is not in doubt; the xref counts below are
the only new claim. A getter with zero xrefs would mean the xref index is stale,
which is checkable against FUN_1801a80c0 (CARD_LEVEL), known to be called from
FUN_1800eb850.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q8_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
P("=== Q-A getter consumers ===")
for nm, a in (("+0x38 DISCARD_CREDITS getter FUN_1801a8620", 0x1801A8620),
("+0x3c CALCULATED getter FUN_1801a8090", 0x1801A8090),
("+0x54 CARD_LEVEL getter FUN_1801a80c0", 0x1801A80C0),
("+0x58 rare==1 getter FUN_1801a88c0", 0x1801A88C0),
("+0x58 rare==0xc getter FUN_1801a8880", 0x1801A8880)):
xs = xrefs_to(a)
P("%-44s %d xrefs" % (nm, len(xs)))
for frm, typ, fn, ent in xs:
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
P("")
P("=== Q-B dispatch-table context around the FUN_1800eb850 slots ===")
for slot in (0x180216130, 0x1802658A0, 0x1802F85EC):
P("-- slot %#x --" % slot)
for off in range(-0x40, 0x48, 8):
a = slot + off
try:
v = qword(a)
except Exception:
P(" %#x <unreadable>" % a)
continue
tag = ""
f = fm.getFunctionAt(addr(v)) if 0x180000000 <= v < 0x181000000 else None
if f:
tag = "-> FUNC %s" % f.getName()
elif 0x1801E5000 <= v <= 0x180290000:
try:
s = rd_str(v, 60)
except Exception:
s = ""
if s and all(32 <= ord(c) < 127 for c in s):
tag = "-> %r" % s
P(" %#x %016x %s%s" % (a, v, tag, " <== the slot" if off == 0 else ""))
P("")
P("=== Q-C writes to the parsed item's +0x34 (frame form RBP+0x194) ===")
f = func(0x18013FE00)
lo = int(f.getBody().getMinAddress().getOffset())
hi = int(f.getBody().getMaxAddress().getOffset())
p = lo
while p <= hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
p += 1
continue
t = str(ins)
if "0x194]" in t or "0x190]" in t or "0x198]" in t or "0x19c]" in t:
P(" %#x %s" % (p, t))
p += ins.getLength()
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,41 @@
"""Side finding follow-up: the parsed item's +0x34 is pushed to the UI as
"BOUGHT_FOR" (FUN_1800eb850, from local_38+0x34) and it is NOT on the field map.
It is written once in the deserializer, at 0x180140885 (MOV [RBP+0x194],EAX).
Q: which wire atom writes it? Print the instruction stream from 0x180140780 to
0x1801408c0 so the dispatch arm and its atom immediate are visible, in whatever
form the compiler chose (cmp / sub-ladder / switch), rather than grepping for
one form.
CONTROL: the same window must also show the neighbouring known write
0x180140e43 -> [RBP+0x190] (item+0x30) or, failing that, at least one arm whose
atom is already in docs/fut_atoms.tsv, so that the arm-reading method is shown
to work on a known case in the same function.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q9_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
for lo, hi, tag in ((0x180140760, 0x1801408D0, "around the +0x34 (BOUGHT_FOR) write"),
(0x180140DC0, 0x180140E60, "around the +0x30 write, as a control")):
P("=== %s : %#x..%#x ===" % (tag, lo, hi))
p = lo
while p < hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
p += 1
continue
P(" %#x %s" % (p, ins))
p += ins.getLength()
P("")
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,51 @@
"""Q1: WHERE DOES THE DISPLAYED RATING COME FROM?
HYPOTHESIS: the card face is painted from an "attribute publisher" that reads the
0x180-stride parsed item record and pushes named ATTRIB_* keys to the UI layer.
CARD_SYSTEM.md already names one such function, FUN_1800e5940, which reads
record+0xe3 and publishes ATTRIB_CONTRACT_NEGOTIATION. If that function also
publishes a rating key, and it reads it from the record (offset 0xb4 per the live
field map) rather than re-querying the local players table, then the displayed
rating is OURS.
CONTROL (same syntactic form): the publisher must also emit the six attribute keys
from +0x98..+0xac. Those are KNOWN-OURS -- CARD_SYSTEM.md records invented
attributes rendering on screen (SILVA 72/44/58/60/62/61). If my read of the
publisher shows the attributes coming from somewhere OTHER than the record, my
read of the publisher is wrong, not the doc, and every rating conclusion in this
file is void.
Also enumerate every ATTRIB_* literal in .rdata with its xrefs, which is the raw
material for the Q2 "ours vs client's" table.
"""
import traceback
try:
print("=== A: every ATTRIB_ literal in .rdata ===")
hits = find_all(b"ATTRIB_")
print("n=%d" % len(hits))
seen = {}
for h in hits:
s = rd_str(h, 96)
seen.setdefault(s, []).append(h)
for s in sorted(seen):
print(" %-46s %s" % (s, " ".join("%#x" % a for a in seen[s])))
print()
print("=== B: xrefs to each ATTRIB_ literal ===")
for s in sorted(seen):
for a in seen[s]:
xs = xrefs_to(a)
if not xs:
continue
print(" %-46s @%#x" % (s, a))
for frm, typ, fn, ent in xs:
print(" from %#x %-12s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== C: FUN_1800e5940 in full ===")
src = dec(0x1800E5940)
print("len=%d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,72 @@
"""Q2: the card-accessor family and the OTHER card publishers.
FUN_1800e5940 is the MANAGER card publisher (gated on CARD_TYPE, emits
ManagerCardBio / TACTICAL_KNOWLEDGE / ATTRIB_TEAM_TALKS). It reads every value it
publishes through a family of tiny accessors at 0x1801a8xxx taking a 4-qword stack
wrapper, and pushes them to a UI sink via param_3 vtable slots +0x08 (bool),
+0x10 (int), +0x20 (string).
HYPOTHESIS: those accessors are thin field reads off the 0x180-stride parsed item
record, so FUN_1801a87f0 (OVERALL_RATING) reads record+0xb4, which is where atom
0x274 rating is stored. If so the DISPLAYED rating is OUR wire value.
CONTROL, same syntactic form: FUN_1801a8540 is published as LEAGUE_ID. LEAGUE_ID
is KNOWN to be client-derived -- FUN_180135890 "always recomputes leagueid +0x154"
on a DB hit. And FUN_1801a8480 is CONTRACT_REMAINING, known-ours (+0x8c, we send
contract 7 and the manager card printed "CONTRACT 7"). So the accessor family must
show BOTH kinds reading the SAME record: that proves the accessors are record
reads and that "ours vs theirs" is decided upstream in the merge, not here. If the
league accessor instead performs a DB query while the rating one does a field read,
that is the opposite answer and it is equally visible.
Also: enumerate every function in [0x1801a7000,0x1801a9000) with its decompiled
body, which is the whole accessor table, and find the sibling publishers by xref
on the UI key literals.
"""
import traceback
try:
print("=== A: UI key literals and their xrefs (finds sibling publishers) ===")
keys = [b"OVERALL_RATING", b"CARD_LEVEL", b"CARD_RARITY", b"CARD_TYPE",
b"ASSET_ID", b"LEAGUE_ID", b"TEAM_ID", b"NATIONALITY",
b"CONTRACT_REMAINING", b"FIRST_NAME", b"LAST_NAME", b"KNOWN_AS",
b"PREFERRED_POSITION", b"POSITION", b"FITNESS", b"PLAYSTYLE",
b"PLAY_STYLE", b"CHEMISTRY", b"ATTRIBUTE", b"ATTR", b"MORALE",
b"TRAINING", b"DISCARD", b"QUICK_SELL", b"UNTRADEABLE",
b"RESOURCE_ID", b"DEFINITION_ID", b"CARD_ASSET", b"ITEM_STATE",
b"ITEM_TYPE", b"SUBTYPE", b"RARE", b"PHOTO", b"HEADSHOT", b"IS_"]
for k in keys:
for a in find_all(k):
s = rd_str(a, 64)
if not s.startswith(k.decode()):
continue
xs = xrefs_to(a)
if not xs:
continue
print(" %-34s @%#x" % (s, a))
for frm, typ, fn, ent in xs:
print(" from %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== B: every function in [0x1801a7000,0x1801a9000) decompiled ===")
it = fm.getFunctions(addr(0x1801A7000), True)
n = 0
while it.hasNext():
f = it.next()
e = int(f.getEntryPoint().getOffset())
if e >= 0x1801A9000:
break
n += 1
src = dec(e)
body = " ".join(src.split())
print("--- %s @%#x len=%d" % (f.getName(), e, len(src)))
print(" %s" % (body[:900]))
print("total functions in range: %d" % n)
print()
print("=== C: FUN_1801a78f0 (builds the wrapper) in full ===")
s = dec(0x1801A78F0)
print("len=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,44 @@
"""Q3: the PLAYER card publisher, the attribute publisher, and the merge.
FUN_1800e6e20 emits OVERALL_RATING, CARD_LEVEL, CARD_RARITY, LEAGUE_ID, TEAM_ID,
LAST_NAME, PREFERRED_POSITION(+_ID), PLAY_STYLE, NATIONALITY(+ABBR/ABBR15/ASSET_ID),
CONTRACT_REMAINING and ASSET_ID -- that is the player card face. Get it in full and
map each published key to the accessor and therefore to a record offset.
FUN_1800e65d0 / FUN_1800e96f0 emit ATTRIBUTE_NAME / _ABBR / _VALUE: the six bars.
FUN_1800ea400 and FUN_1800e86f0 and FUN_1800eb850 are the other publishers.
HYPOTHESIS: every one of these is a pure field read off the same 0x180 record, so
the "ours vs the client's" split is decided ONLY in the merge FUN_180141660 /
FUN_180135890, never at draw time.
CONTROL: FUN_1800e6e20 must publish LAST_NAME from record+0xc8 (an inline char
array the merge WRITES from the local DB) and OVERALL_RATING from record+0xb4 (a
field the merge is documented never to touch). Both in the same function, same
call form. If they do not both appear as record reads, my model of the publisher
is wrong.
Also dump FUN_180135890 (players merge, the Q4 miss path) and FUN_180141660 in
full, plus the CARD_TYPE getter FUN_18003b800 and FUN_18003cbf0.
"""
import traceback
FUNCS = [
("FUN_1800e6e20 PLAYER card publisher", 0x1800E6E20),
("FUN_1800e65d0 attribute publisher A", 0x1800E65D0),
("FUN_1800e96f0 attribute publisher B", 0x1800E96F0),
("FUN_1800ea400 publisher", 0x1800EA400),
("FUN_1800e86f0 publisher", 0x1800E86F0),
("FUN_1800eb850 publisher", 0x1800EB850),
("FUN_18015fa80 publisher", 0x18015FA80),
("FUN_1800e80f0 publisher", 0x1800E80F0),
]
try:
for label, ea in FUNCS:
src = dec(ea)
print("=" * 70)
print("### %s @%#x len=%d" % (label, ea, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,59 @@
"""Q4: the deserializer's atom->offset table, the merge, and the blank-card path.
Three questions, one batch.
(a) THE WIRE SIDE. FUN_18013fe00 is the shared ITEM element deserializer. Print it
IN FULL (never truncated -- see the absence trap) so the atom dispatch can be
enumerated in every form: == 0xNN, != 0xNN, switch case labels and running-sum
ladders. Atoms of interest: 0x274 rating, 0x287 resourceId, 0x23 assetId,
0x6b cardassetid, 0x6c cardsubtypeid, 0x23f playStyle, 0x24a preferredPosition,
0x31 attributeList, 0x271 rareflag, 0x172 itemState, 0x173 itemType.
HYPOTHESIS: rating 0x274 -> record+0xb4, and playStyle 0x23f has NO arm (the
live sweep found no 0xfa anywhere in a record although we send playStyle 250,
and the player publisher reads PLAY_STYLE from +0x88).
CONTROL: the same enumeration must FIND 0x274 and 0x287, which are known to be
parsed. An enumeration that finds neither is a broken enumeration, not an
absent atom.
(b) THE MERGE. FUN_180141660 (dispatch on record+0x4c) and FUN_180135890 (players
branch). Q4 asks what exactly the client failed to find for a blank card and
what the minimum viable identity is.
(c) THE ACCESSORS the player publisher uses, in full and untruncated, so each
published UI key gets an exact record offset.
"""
import traceback
ACC = [0x1801A8010, 0x1801A8020, 0x1801A8090, 0x1801A80A0, 0x1801A80C0,
0x1801A8100, 0x1801A8110, 0x1801A8120, 0x1801A8130, 0x1801A8140,
0x1801A8460, 0x1801A8480, 0x1801A8490, 0x1801A84A0, 0x1801A8550,
0x1801A8590, 0x1801A85C0, 0x1801A85D0, 0x1801A8600, 0x1801A8620,
0x1801A8640, 0x1801A8660, 0x1801A86A0, 0x1801A86B0, 0x1801A87F0,
0x1801A8890, 0x1801A88D0, 0x1801A8950, 0x1801A8940]
try:
print("=== C: accessors in full ===")
for a in ACC:
s = dec(a)
print("--- %#x len=%d" % (a, len(s)))
print(s)
print()
print("=== B: merge dispatch FUN_180141660 ===")
s = dec(0x180141660)
print("len=%d" % len(s))
print(s)
print()
print("=== B2: players merge FUN_180135890 ===")
s = dec(0x180135890)
print("len=%d" % len(s))
print(s)
print()
print("=== A: item deserializer FUN_18013fe00 IN FULL ===")
s = dec(0x18013FE00, 600)
print("len=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,77 @@
"""Q5: resourceId decomposition, the value mappers, and who reads assetId (+0x20).
Established so far, and the reason for each question here:
* FUN_180141660's TAIL, reached on EVERY family including the no-merge default,
does record+0x54 = 3 if record+0xb4 >= 0x4b else 2 if >= 0x41 else 1. So +0x54
is the bronze/silver/gold CARD LEVEL derived from rating, not itemType.
* The players merge queries `players` by record+0x18 & 0xffffff and, on 0 rows,
OVERWRITES rating=0x32, attrs=1, teamid=0x78d, nation=0xe, position=2, name=" ".
* The item deser routes atom 0x1d1 nation to +0x148 for family 1 and +0xde for
family 2 and NOWHERE otherwise, and atom 0x18a leagueId always to +0xe0.
Remaining:
(a) FUN_180166ca0(resourceId, &out_byte, buf, &out_dword) -- the resourceId
decomposition. out_dword lands at record+0x18 and out_byte at record+0x24,
and +0x24 gates the "p%d.dds" FUTPlayerHeads photo registration. This decides
Q3: what resourceId actually has to look like.
CONTROL: DAT_7364642e642570 must read as "p%d.dds"; if it does not, my read of
the photo path is wrong.
(b) FUN_180136480 playStyle mapper, FUN_180166810 preferredPosition mapper,
FUN_180166660 itemState mapper, FUN_1801666f0 injuryType mapper.
(c) WHO READS record+0x20 (assetId)? Accessors FUN_1801a8020 and FUN_1801a8990
read it. Enumerate their callers. HYPOTHESIS: only the staff/manager card
art path, which the merge itself fills from the local DB, so a wire assetId
is dead for every family.
CONTROL: the same enumeration run on FUN_1801a87f0 (+0xb4 rating) must return
the publishers we already found. An enumeration that returns nothing for
rating is broken.
(d) FUN_1800d84e0 -- the value written to record+0x30.
(e) FUN_1801356c0 manager merge, for the family table.
"""
import traceback
try:
print("=== CONTROL: the format string at 0x1801eaf90..0x1801eafa8 region ===")
for a in (0x1801EAF98,):
print(" %#x -> %r" % (a, rd_str(a, 32)))
hits = find_all(b"p%d.dds")
print(" 'p%%d.dds' occurrences: %s" % " ".join("%#x" % h for h in hits))
for h in find_all(b"FUTPlayerHeads"):
print(" FUTPlayerHeads @%#x" % h)
print()
print("=== (a) FUN_180166ca0 resourceId decomposition ===")
print(dec(0x180166CA0))
print("=== (b) mappers ===")
for lbl, a in (("playStyle FUN_180136480", 0x180136480),
("preferredPosition FUN_180166810", 0x180166810),
("itemState FUN_180166660", 0x180166660),
("injuryType FUN_1801666f0", 0x1801666F0)):
s = dec(a)
print("--- %s len=%d" % (lbl, len(s)))
print(s)
print("=== (c) callers of the accessors ===")
for lbl, a in (("+0x20 assetId FUN_1801a8020", 0x1801A8020),
("+0x20/+0x4c FUN_1801a8990", 0x1801A8990),
("+0x1c cardassetid FUN_1801a8010", 0x1801A8010),
("+0x18 resourceId FUN_1801a80a0", 0x1801A80A0),
("CONTROL +0xb4 rating FUN_1801a87f0", 0x1801A87F0),
("+0x54 level FUN_1801a80c0", 0x1801A80C0),
("+0x54 FUN_1801a8870", 0x1801A8870),
("+0x54 FUN_1801a88f0", 0x1801A88F0)):
print("--- %s" % lbl)
for frm, typ, fn, ent in xrefs_to(a):
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== (d) FUN_1800d84e0 (record+0x30) ===")
print(dec(0x1800D84E0))
print("=== (e) FUN_1801356c0 manager merge ===")
s = dec(0x1801356C0)
print("len=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,46 @@
"""Q6: loose ends.
(a) _DAT_1801f66a0 -- the qword the deser uses to initialise {cardsubtypeid(+0x50),
+0x54}. If +0x54 is the card LEVEL and no atom writes it, its pre-merge value
is whatever this constant carries.
(b) Does ANY writer of record+0x54 exist besides FUN_180141660's tail? Enumerate
every function that references the level accessors and, more directly, look for
the constant 0x4b/0x41 rating-tier ladder elsewhere.
(c) FUN_18003b800 -- the CARD_TYPE getter whose value 0x12 gates the manager
publisher. What is the enum?
(d) FUN_1801a7dd0 / FUN_1801a78f0 -- how the publisher's wrapper gets its record
pointer, to confirm wrapper+0x18 IS the 0x180 item record and not a copy.
CONTROL: FUN_1801a7100 already visibly does param_1[3] = *(param_2+0x10), i.e.
wrapper+0x18 = arg+0x10; if FUN_1801a7dd0 disagrees my offsets are off.
"""
import traceback
try:
print("=== (a) _DAT_1801f66a0 ===")
q = qword(0x1801F66A0)
print(" qword %#018x -> low dword %#x (+0x50 init) high dword %#x (+0x54 init)"
% (q, q & 0xFFFFFFFF, q >> 32))
print()
print("=== (b) other rating-tier ladders: functions comparing a byte to 0x4b ===")
for lbl, a in (("FUN_180141660 tail", 0x180141660),
("FUN_1801a80c0 accessor", 0x1801A80C0)):
print(" known: %s" % lbl)
print(" callers of FUN_1800d8330 (family map), for context:")
for frm, typ, fn, ent in xrefs_to(0x1800D8330):
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== (c) FUN_18003b800 CARD_TYPE getter ===")
print(dec(0x18003B800))
print("=== FUN_18003b9d0 and FUN_18003cbf0 (manager publisher gates) ===")
print(dec(0x18003B9D0))
print(dec(0x18003CBF0))
print("=== (d) wrapper construction ===")
for a in (0x1801A7DD0, 0x1801A78F0, 0x1801A7100):
s = dec(a)
print("--- %#x len=%d" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,37 @@
"""Q7: completeness check on the headline claim.
CLAIM: the displayed rating is record+0xb4 and nothing else.
The evidence so far is that the literal OVERALL_RATING has exactly 4 xrefs and all 4
functions call FUN_1801a87f0 (+0xb4). That is only airtight if OVERALL_RATING is the
ONLY key under which a rating reaches the UI. Enumerate every .rdata literal that
contains "RATING" or "OVR" and report its xrefs, so a second rating key cannot hide.
CONTROL: the enumeration must re-find OVERALL_RATING with its 4 known xrefs.
"""
import traceback
try:
seen = {}
for pat in (b"RATING", b"OVR", b"Rating"):
for a in find_all(pat):
# walk back to the start of the C string
s = a
while s > a - 64:
try:
if mem.getByte(addr(s - 1)) & 0xFF == 0:
break
except Exception:
break
s -= 1
txt = rd_str(s, 96)
if txt:
seen.setdefault(txt, set()).add(s)
for txt in sorted(seen):
for a in sorted(seen[txt]):
xs = xrefs_to(a)
if not xs:
continue
print("%-44s @%#x" % (txt[:44], a))
for frm, typ, fn, ent in xs:
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,16 @@
"""Q8: the four publishers of the bare "RATING" key -- do any bypass record+0xb4?
If one of them reads a rating from the local DB instead of the item record, the
headline claim ("the displayed rating is ours") needs qualifying by screen.
CONTROL: FUN_18003ded0 also publishes FIRST_NAME/LAST_NAME, so it is card-shaped and
is the most likely counterexample. Print all four in full.
"""
import traceback
try:
for a in (0x18003DED0, 0x180053AA0, 0x1800F0970, 0x18005F910):
s = dec(a)
print("=" * 60)
print("### %#x len=%d" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,19 @@
"""Q9: why is record+0xe0 zero although atom 0x18a leagueId writes local_a8?
The struct-offset formula (offset = 0x188 - localnum) is validated empirically on
NINE fields (contract +0x8c, rating +0xb4, discardValue +0x38, cardsubtypeid +0x50,
teamid +0x94, nation +0x148, owners +0x48, cardassetid +0x1c, assetId +0x20), yet a
live read shows +0xe0 == 0 on all 22 records while we send leagueId 13..353.
Candidate: FUN_180134cb0, called at the tail of FUN_180135890, clears the manager
block for family 1.
CONTROL: whatever it does must NOT clear +0xb4/+0x94/+0x148, which the live read
shows surviving.
"""
import traceback
try:
for a in (0x180134CB0,):
s = dec(a)
print("### %#x len=%d" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,50 @@
"""q_cd_route_1 -- the club route family.
HYPOTHESIS
The 125-row action table at 0x1802caa20 binds each client-originated request to a
URL base out of the 48-row base table at 0x18021df80 (16-byte rows: char* template,
char* symbolic name). Exactly four rows carry base index 3 = "ut/%s/club":
ClubSearch (fn 0x180123a60), ClubStats (0x180123a70), StaffStats (0x1801247e0),
ConsumablesSearch (0x180123a80). If that is right, those four factories are the
COMPLETE set of club-route request builders and everything the client can emit on
/club is one of them.
CONTROL
Decompile a NON-club factory from the same table in the same pass (PurchasedItems
0x180124260, base 26 = ut/%s/purchased) and confirm it resolves to a different
request class with a different suffix builder. If the four club factories and the
control all decompile to the same shape, the shape is real; if the control comes
back empty while the targets do too, the method is broken, not the answer.
Second control: the /stats/%s, /stats/staff and /consumables/%s literals were found
by RIP-relative displacement scan of .text on the ON-DISK PE, at 0x18012f5b5,
0x18012b086 and 0x18013090e. Those three functions must turn out to be the URL
builders of three of the four classes. If they are not, the base-index reading of
column 1 is wrong.
"""
import traceback
try:
TARGETS = [
("ClubSearch.factory", 0x180123a60),
("ClubStats.factory", 0x180123a70),
("StaffStats.factory", 0x1801247e0),
("ConsumablesSearch.factory", 0x180123a80),
("CONTROL PurchasedItems.factory", 0x180124260),
("uses /stats/%s", 0x18012f5b5),
("uses /stats/staff", 0x18012b086),
("uses /consumables/%s", 0x18013090e),
("uses both enum tables", 0x180166306),
]
for label, ea in TARGETS:
f = func(ea)
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
continue
print(" entry %s" % f.getEntryPoint())
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,79 @@
"""q_cd_route_2 -- the ClubSearch (FutStickerBookSearch) query-string builder.
HYPOTHESIS
The club item list is the "StickerBookSearch" request (RS4 literal at 0x180221e48).
Its literal block holds "%s%s=%s" 0x180221e78, "%s%s=%d" 0x180221e80, "%s%s="
0x180221e94, "2017", "desc", "asc", so the query string is assembled key by key with
a separator+name+value printf, and the key names come out of the atom reverse
lookup FUN_180180cd0 exactly as they do in the /stats/%s builder FUN_18012f4f0
(already decompiled: 6 cases, atoms 0x87 club / 0x389 year / 0xbd country /
0x189 league / 0x1d7 newcards / 0xa5 consumables).
A RIP-displacement scan of .text on the on-disk PE puts every use of those three
printf formats in 0x18012deb0..0x18012e5bd, one contiguous region, plus two strays
at 0x180169357/0x1801693ac and one at 0x180163a1c.
CONTROL
FUN_18012f4f0 (/stats/%s) decompiled cleanly in q_cd_route_1 through the same
helpers, so a NO FUNC or an empty body here is a fact about this address, not about
the harness. Also included: the two enum reverse-lookups FUN_180166300 (table
0x180229ab0) and the sibling at 0x180166340 (table 0x180229c30), and their callers,
because those tables are the candidate ?type= vocabularies and a table with no
caller inside a URL builder proves nothing about the wire.
Absence discipline: the atom-id switch in a builder is a `switch` with case labels,
so it is enumerated by reading the decompile in full, not by grepping "== 0x".
"""
import traceback
try:
def show(label, ea, full=True):
f = func(ea)
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return None
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
if full:
print(src)
return f
seen = set()
for ea in (0x18012deb0, 0x18012df5c, 0x18012e087, 0x18012e19f, 0x18012e202,
0x18012e256, 0x18012e2d0, 0x18012e322, 0x18012e5bd,
0x18012dfb0, 0x18012e003, 0x18012e36d, 0x18012e3b8, 0x18012e403,
0x18012e44e, 0x18012e499, 0x18012e0db):
f = fm.getFunctionContaining(addr(ea))
if f is None:
print("### query-fmt use @ %s -> NO FUNC" % hex(ea))
continue
k = int(f.getEntryPoint().getOffset())
if k in seen:
continue
seen.add(k)
show("query-fmt user (via %s)" % hex(ea), k)
for ea in (0x180169357, 0x180163a1c):
f = fm.getFunctionContaining(addr(ea))
if f is None:
print("### stray fmt use @ %s -> NO FUNC" % hex(ea))
continue
k = int(f.getEntryPoint().getOffset())
if k not in seen:
seen.add(k)
show("stray query-fmt user (via %s)" % hex(ea), k)
show("enum revlookup table 0x180229ab0", 0x180166300)
show("enum revlookup table 0x180229c30", 0x180166340)
for ea in (0x180166300, 0x180166340):
f = fm.getFunctionContaining(addr(ea))
if f is None:
continue
print("--- callers of %s:" % hex(ea))
for c in sorted(set(int(x.getEntryPoint().getOffset()) for x in f.getCallingFunctions(mon))):
print(" %s %s" % (hex(c), fm.getFunctionAt(addr(c)).getName()))
show("StaffStats suffix (/stats/staff user)", 0x18012b086)
except Exception:
traceback.print_exc()
@@ -0,0 +1,51 @@
"""q_cd_route_3 -- the ?type= vocabulary itself, and the remaining club value maps.
HYPOTHESIS
FUN_18012ddf0 (the ClubSearch / FutStickerBookSearch query builder, decompiled in
q_cd_route_2) writes the type= parameter from FUN_18012ec50(code), where code is
*(req+0x10) after a 3-case remap of *(req+0x14). FUN_18012ec50 is therefore the
COMPLETE code->string map for club?type=, and enumerating its cases (switch labels
AND == AND != AND any sub/dec ladder, read from a full-length decompile) closes the
vocabulary. The one branch that bypasses it is code 0xf, which picks
badge/kit/stadium/ball/equippables from *(req+0x30).
CONTROLS
* FUN_18012ec50 is decompiled alongside three sibling value maps used by the same
builder for other parameters -- FUN_1800d8b50 (position=), FUN_180166620
(formation=), FUN_18012ee20 (state=). If FUN_18012ec50 came back as a stub while
those three came back as real tables, the stub is a fact; if all four are stubs
the method is at fault.
* Both enum reverse-lookups over the two candidate tables are re-printed with their
callers, so "table 0x180229c30 is/is not the type= vocabulary" is decided by a
caller edge and not by the table's contents looking plausible.
* len(src) is printed for every function and every body is printed in full, so no
absence claim here rests on a truncated decompile.
"""
import traceback
try:
def show(label, ea):
f = fm.getFunctionContaining(addr(ea)) if ea else None
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
try:
cs = sorted(set(int(x.getEntryPoint().getOffset())
for x in f.getCallingFunctions(mon)))
print(" callers: %s" % ", ".join(hex(c) for c in cs))
except Exception as e:
print(" callers: <%s>" % e)
show("type= value map FUN_18012ec50", 0x18012ec50)
show("position= value map FUN_1800d8b50", 0x1800d8b50)
show("formation= value map FUN_180166620", 0x180166620)
show("state= value map FUN_18012ee20", 0x18012ee20)
show("enum revlookup 0x180229ab0", 0x180166300)
show("enum revlookup 0x180229c30", 0x180166340)
except Exception:
traceback.print_exc()
@@ -0,0 +1,54 @@
"""q_cd_route_4 -- close the bound: how base+suffix are actually composed.
HYPOTHESIS
Column 1 of each 48-byte row of the 125-row action table at 0x1802caa20 is an index
into the 48-row URL-base table at 0x18021df80, and the URL is base-template then the
class's own suffix/query builder. Exactly four rows carry index 3 = "ut/%s/club".
If that is right, the club route family is closed by the table.
KNOWN SOFT SPOT, the reason for this query: no row carries index 43 = "ut/v2/%s/store",
yet ut/v2/%s/store is a live-proven route. So either the base index can be overridden
at runtime, or the base table is indexed from somewhere else as well. Until that is
settled the "exactly four" bound is a strong default, not an absolute.
CONTROL
The one RIP-relative reference to the action table found by scanning .text of the
on-disk PE is at 0x180123f46. Decompiling its container should show the row layout
being read (name, base index, upper name, flag, factory) -- if the field it reads at
+0x08 is NOT used as a table index, the base-index reading is wrong and every
"exactly four" statement in this dimension has to be withdrawn.
Second target: 0x18012ea74 is the only reference to the literal "club" at
0x180221e40, which sits inside the FutStickerBookSearch literal block. Printing it
says whether the club-search class composes its own base (which would make the base
table irrelevant for it) or uses the literal for something else entirely.
Everything is printed with len(src) and in full; no absence claim rests on a
truncated body.
"""
import traceback
try:
def show(label, ea):
f = fm.getFunctionContaining(addr(ea))
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
try:
cs = sorted(set(int(x.getEntryPoint().getOffset())
for x in f.getCallingFunctions(mon)))
print(" callers: %s" % ", ".join(hex(c) for c in cs))
except Exception as e:
print(" callers: <%s>" % e)
show("action-table reader (only ref to 0x1802caa20)", 0x180123f46)
show("only ref to \"club\" literal 0x180221e40", 0x18012ea74)
show("URL append helper used by every suffix builder", 0x180008020)
show("StaffStats suffix thunk (raw addr 0x18012b080)", 0x18012b080)
except Exception:
traceback.print_exc()
@@ -0,0 +1,44 @@
"""q_cd_route_5 -- is column 1 of the action row really the URL-base index?
HYPOTHESIS
Row N of the action table 0x1802caa20 (48-byte rows) has column 1 = index into the
16-byte-stride (template, symbolic name) table at 0x18021df80, and the four club
actions carry index 3. RIP-displacement scan of .text on the on-disk PE finds only
four references anywhere near that table: 0x180123713 (-24), 0x180123790 (-64),
0x18012437e (+8) and 0x1801db62c (-64). Decompiling their containers should show
one of them doing table[i*2] / table[i*2+1] with i coming from the action row.
CONTROL / FALSIFIER
ut/v2/%s/store (index 43) is a LIVE-PROVEN route and NO action row carries index 43.
So if these functions show the index arriving only from the action row, the club
bound is exact for every action in the table but the store proves some other path
exists, and the bound must be stated with that caveat. If instead they show a
runtime override (a per-request base field, or a second table), the "exactly four
club actions" claim is a default and not a closure, and this query is what says so.
Every body is printed in full with len(src).
"""
import traceback
try:
def show(label, ea):
f = fm.getFunctionContaining(addr(ea))
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
try:
cs = sorted(set(int(x.getEntryPoint().getOffset())
for x in f.getCallingFunctions(mon)))
print(" callers: %s" % ", ".join(hex(c) for c in cs))
except Exception as e:
print(" callers: <%s>" % e)
for ea in (0x180123713, 0x180123790, 0x18012437e, 0x1801db62c):
show("base-table ref", ea)
except Exception:
traceback.print_exc()
@@ -0,0 +1,196 @@
"""D3 Q1: the itemState vocabulary and the lifecycle-field arms in the shared item deser.
HYPOTHESIS: FUN_18013fe00 (shared ITEM element deser) has an arm for atom 0x172
(itemState) that reads a STR and maps it through the enum table documented at
0x180229d20 (stride 0x10: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge,
activeHomeKit, activeAwayKit, activeBall, activeStadium, active). That documented
list omits "free", which we send on every card, so either the table is longer than
recorded or "free" is the default/no-match value. Also locate the arms for
pile 0x226, pileType 0x228, owners 0x207, untradeable 0x361, untradeableCount 0x362,
tradeId 0x331, loans 0x19b, itemLoans 0x16f, duplicateItemLoans 0xed,
tradeState 0x335, contract 0xa9?(unknown, resolved from tsv below).
CONTROL (same syntactic form as the target): the item deser is known to store our
discardValue (atom 0xd7) at item+0x38. If the immediate-scan below does not find
0xd7 in FUN_18013fe00 in the SAME form (case label / cmp / ladder) then the scan is
broken and every absence claim in this batch is void. Second control: 0x172 itself
appears in docs as reaching the enum table, so xrefs_to(0x180229d20) must be
non-empty.
ABSENCE TRAP GUARD: we do not grep "== 0x". We enumerate every immediate operand of
every instruction in the function (scalar operands of any size), which catches
`cmp ==`, `cmp !=`, jump-table `case` labels only indirectly, and running-sum
sub/dec ladders (the ladder deltas are computed and searched too). Jump tables are
handled separately by walking every switch construct Ghidra knows about.
OUTPUT: full decompiles printed untruncated with len(src); full table dump; full
immediate census.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/"
os.makedirs(OUT, exist_ok=True)
ATOMS = {
0x172: "itemState", 0x226: "pile", 0x227: "pileSizeClientData", 0x228: "pileType",
0x207: "owners", 0x361: "untradeable", 0x362: "untradeableCount",
0x331: "tradeId", 0x332: "tradepile", 0x333: "tradePile",
0x19b: "loans", 0x16f: "itemLoans", 0xed: "duplicateItemLoans",
0x335: "tradeState", 0xd7: "discardValue(CONTROL)", 0x19: "allowUntradeableForSquadBuildingSets",
}
def dump(tag, va, echo=True):
f = func(va)
if f is None:
print("%s %#x -> NO FUNCTION" % (tag, va))
return ""
src = dec(va)
print("=" * 78)
print("%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (tag, va, f.getName(), len(src)))
print("=" * 78)
if echo:
print(src)
with open(OUT + "q1_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
return src
def immediates(va):
"""Every scalar operand of every instruction in the function containing va."""
f = func(va)
out = {}
if f is None:
return out
body = f.getBody()
it = listing.getInstructions(body, True)
n = 0
while it.hasNext():
ins = it.next()
n += 1
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
out.setdefault(v & 0xFFFFFFFFFFFFFFFF, []).append(
(int(ins.getAddress().getOffset()), str(ins)))
print("[immediates] %s %#x: %d instructions, %d distinct scalars"
% (f.getName(), va, n, len(out)))
return out
def report_atoms(va, imms):
"""Direct hits + running-sum ladder reconstruction."""
print("--- atom census for %#x (%s)" % (va, fname(va)))
for a, name in sorted(ATOMS.items()):
hits = imms.get(a, [])
if hits:
print(" DIRECT 0x%-4x %-38s x%d first@%#x %s"
% (a, name, len(hits), hits[0][0], hits[0][1]))
# ladder reconstruction: walk instructions in address order, keep a running
# sum of sub/dec/add immediates on the dispatch register, report any partial
# sum that equals one of our atoms.
f = func(va)
if f is None:
return
it = listing.getInstructions(f.getBody(), True)
running = {}
while it.hasNext():
ins = it.next()
m = ins.getMnemonicString().lower()
if m not in ("sub", "add", "dec", "inc", "cmp", "lea"):
continue
try:
reg = str(ins.getOpObjects(0)[0])
except Exception:
continue
val = None
for o in ins.getOpObjects(1) if ins.getNumOperands() > 1 else []:
try:
val = int(o.getValue())
except Exception:
pass
if m == "dec":
val = 1
if m == "inc":
val = -1
if val is None:
continue
if m in ("sub", "dec"):
running[reg] = running.get(reg, 0) + val
elif m in ("add", "inc"):
running[reg] = running.get(reg, 0) - val
cur = running.get(reg)
if cur in ATOMS:
print(" LADDER 0x%-4x %-38s @%#x %s (running sum on %s)"
% (cur, ATOMS[cur], int(ins.getAddress().getOffset()), ins, reg))
def switch_labels(va):
"""Every switch case label Ghidra resolved inside the function."""
f = func(va)
if f is None:
return
from ghidra.program.model.symbol import FlowType # noqa
it = listing.getInstructions(f.getBody(), True)
tot = 0
while it.hasNext():
ins = it.next()
ft = ins.getFlowType()
if ft is not None and ft.isJump() and ft.isComputed():
tgts = ins.getFlows()
print(" SWITCH @%#x %s -> %d targets" % (int(ins.getAddress().getOffset()), ins, len(tgts)))
tot += len(tgts)
if tot:
print(" (computed-jump targets total %d)" % tot)
try:
print("###### PART A: the itemState enum table at 0x180229d20")
# walk generously in both directions; entries are (char* name, ...) stride 0x10
base = 0x180229d20
for off in range(-0x200, 0x400, 0x10):
a = base + off
try:
p = qword(a)
q = qword(a + 8)
except Exception:
continue
s = ""
if 0x180000000 <= p < 0x181000000:
try:
s = rd_str(p, 64)
except Exception:
s = "<unreadable>"
print(" %#x (%+#5x) p=%#018x q=%#018x str=%r" % (a, off, p, q, s))
print()
print("###### PART B: who references the table")
for t in (0x180229d20,):
for frm, typ, fn, ent in xrefs_to(t):
print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent))
print()
print("###### PART C: shared item deser FUN_18013fe00")
src = dump("itemdeser", 0x18013fe00)
imms = immediates(0x18013fe00)
report_atoms(0x18013fe00, imms)
switch_labels(0x18013fe00)
print()
print("###### PART D: full sorted immediate dump for 0x18013fe00 (values < 0x1000)")
for v in sorted(k for k in imms if k < 0x1000):
print(" 0x%-4x n=%-3d %s" % (v, len(imms[v]), imms[v][0][1]))
print()
print("###### PART E: callees of the item deser")
try:
for c in callees(0x18013fe00):
print(" callee", c)
except Exception as e:
print(" callees() failed:", e)
except Exception:
traceback.print_exc()
@@ -0,0 +1,21 @@
"""D3 Q10 (control): is FUN_1800e2a40 really vtable slot +0x40 of the object that
FUN_18003e370 calls? The whole action-flag attribution rests on that. Find every
.rdata/.data table containing the pointer and print the slot index and neighbours;
slot +0x40 = index 8. CONTROL in the same run: FUN_18013fe00 is a known vtable
member? no -- instead use FUN_1801a7260, whose two DATA xrefs we already saw, and
check the pointer search finds them."""
import struct, traceback
try:
for tag, va in (("actionflags_1800e2a40", 0x1800e2a40),
("gate_1801a7260(CONTROL: 2 DATA xrefs expected)", 0x1801a7260)):
hits = find_all(struct.pack("<Q", va), blocks=(".rdata", ".data"))
print("=== %s %#x : %d pointer slots" % (tag, va, len(hits)))
for h in hits:
print(" at %#x" % h)
for k in range(-10, 4):
try: t = qword(h + k*8)
except Exception: continue
nm = fname(t) if 0x180000000 <= t < 0x181000000 else ""
print(" slot %+#5x %#x %s" % (k*8, t, nm))
except Exception:
traceback.print_exc()
@@ -0,0 +1,53 @@
"""D3 Q11: fix the failed control from Q10.
Q10's control was INVALID BY CONSTRUCTION, not a scan failure: xrefs_to(0x1801a7260)
reported two DATA references, but those live at 0x180284468 / 0x180300d1c which are
4-byte RVA entries (RVA of the function is 0x1a7260) in the exception/unwind tables,
so an 8-byte-pointer search can never find them. Re-do the control properly and then
answer the real question: what is the slot INDEX of FUN_1800e2a40 in the table that
contains it? The action-flag attribution needs index 8 (= vtable +0x40).
CONTROL (valid this time): the same 8-byte pointer search must find FUN_18013fe00,
which we know sits nowhere in a vtable, AND must find the item vtable 0x1801eaac0's
own three slots. Concretely: read 0x1801eaac0 and confirm its slots are .text
addresses -- an 8-byte-pointer table we have already relied on.
"""
import struct, traceback
try:
print("### the 4-byte RVA check that explains the Q10 control failure")
for a in (0x180284468, 0x180300d1c):
try:
v = dword(a)
print(" %#x -> dword %#x (RVA of 0x1801a7260 is 0x1a7260)" % (a, v))
except Exception as e:
print(" %#x unreadable %s" % (a, e))
print()
print("### CONTROL: the known item vtable 0x1801eaac0")
for k in range(4):
t = qword(0x1801eaac0 + k*8)
print(" slot %+#4x %#x %s" % (k*8, t, fname(t) if 0x180000000 <= t < 0x181000000 else ""))
print()
print("### walk back from 0x180215b50 to the start of its pointer table")
a = 0x180215b50
start = a
while True:
p = a - 8
try:
t = qword(p)
except Exception:
break
if not (0x180001000 <= t < 0x1801d0000):
break
start = p
a = p
if 0x180215b50 - start > 0x800:
break
print(" table start %#x, FUN_1800e2a40 is at %#x -> slot %#x (index %d)"
% (start, 0x180215b50, 0x180215b50 - start, (0x180215b50 - start)//8))
for off in range(0, 0x180215b50 - start + 0x40, 8):
t = qword(start + off)
print(" %+#6x %#x %s" % (off, t, fname(t) if 0x180000000 <= t < 0x181000000 else ""))
except Exception:
traceback.print_exc()
@@ -0,0 +1,22 @@
"""D3 Q12: close the last gap -- is FUN_1800e2a40 the thing FUN_18003e370 calls?
Its only 8-byte pointer slot is at +0xd0 of one table, not +0x40, so either the call
goes through a thunk or the attribution is wrong. Print all callers, and print the
service constructor FUN_180018bd0 so the interface can be named.
CONTROL: the semantic anchor is independent of the vtable -- byte[2] of the output
array is computed as (isPlayer && squadHasRoom && !alreadyInSquad), which can only be
TO_ACTIVE_SQUAD, the 3rd of the eight names. If callers show a thunk, both lines of
evidence agree; if not, the semantic anchor still stands alone."""
import traceback
try:
print("### callers of FUN_1800e2a40")
for c in callers(0x1800e2a40):
print(" ", c)
for frm, typ, fn, ent in xrefs_to(0x1800e2a40):
print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent))
print()
print("### FUN_180018bd0 (the service FUN_18003e370 asks for)")
print(dec(0x180018bd0))
print("### FUN_18003cbf0 (CARD_ID extraction, 2nd arg to slot 0x40)")
print(dec(0x18003cbf0))
except Exception:
traceback.print_exc()
@@ -0,0 +1,167 @@
"""D3 Q2: the itemState string<->enum functions, and who READS the state slots.
ESTABLISHED BEFORE THIS QUERY (q_cd_state_1 + live read, both controls passed):
the deser's stack struct base is local_188 (anchored by local_118 = &PTR_LAB_1801eaac0
landing at record+0x70, the known item vtable), so record_off = 0x188 - N.
itemState (atom 0x172) -> FUN_180166660(str) -> record +0x5c (live: 1 == "free")
owners (atom 0x207) -> record +0x48 u8 (live: 1)
untradeable(atom 0x361)-> record +0x49 u8 = (untradeable == FALSE), a TRADEABLE flag
(live: 0 on all 22)
the enum table is at 0x180229cc0 (NOT 0x180229d20, which is mid-table):
invalid 0, free 1, WAITING_FOR_GAME 2, inGame 2, forSale 5, offered 6,
activeBadge 0x64, activeHomeKit 0x65, activeAwayKit 0x66, activeBall 0x67,
activeStadium 0x68, active 0xff, terminator {NULL, 0xffffffff}
HYPOTHESIS: FUN_180166660 walks that table and returns a default for no-match; there is
a matching enum->string writer used when the client SENDS itemState back; and the UI
gating for "list on transfer market" / "quick sell" reads +0x49 and/or +0x5c.
CONTROL for the reader scan (same syntactic form as the target -- a memory operand with
a small displacement): the scan must also find the KNOWN readers of +0x38/+0x3c
(discardValue) and of +0x70 (the vtable). If displacement 0x38 or 0x70 comes back with
zero hits the scan is broken and every absence below is void.
OUTPUT: full decompiles (len printed), full table walk, full grouped reader census.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/"
os.makedirs(OUT, exist_ok=True)
STATE_VALUES = {0, 1, 2, 5, 6, 0x64, 0x65, 0x66, 0x67, 0x68, 0xff}
def dump(tag, va):
f = func(va)
if f is None:
print("%s %#x -> NO FUNCTION" % (tag, va))
return ""
src = dec(va)
print("=" * 78)
print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src)))
print("=" * 78)
print(src)
with open(OUT + "q2_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
return src
try:
print("###### A. string->enum helpers used by the item deser")
for tag, va in (("itemState_0x172", 0x180166660),
("injuryType_0x168", 0x1801666f0),
("prefPos_0x24a", 0x180166810),
("resourceId_split_0x287", 0x180166ca0),
("playStyle_0x23f", 0x180136480)):
dump(tag, va)
print()
print("###### B. every xref into the itemState table block 0x180229cc0..0x180229d80")
for a in range(0x180229cc0, 0x180229d90, 8):
xs = xrefs_to(a)
for frm, typ, fn, ent in xs:
print(" table%+#5x %#x xref from %#x %s in %s (%#x)"
% (a - 0x180229cc0, a, frm, typ, fn, ent))
print()
print("###### C. xrefs to each itemState string literal (finds the enum->string writer)")
for name in (b"invalid\x00", b"free\x00", b"WAITING_FOR_GAME\x00", b"inGame\x00",
b"forSale\x00", b"offered\x00", b"activeBadge\x00", b"activeHomeKit\x00",
b"activeAwayKit\x00", b"activeBall\x00", b"activeStadium\x00",
b"tradepile\x00", b"club\x00", b"trade\x00", b"pile\x00"):
hits = find_all(name)
print(" %-20s occurrences=%d" % (name.decode().strip("\x00"), len(hits)))
for h in hits:
for frm, typ, fn, ent in xrefs_to(h):
print(" @%#x xref %#x %s in %s (%#x)" % (h, frm, typ, fn, ent))
print()
print("###### D. callers of FUN_180166660 (every itemState parse site)")
try:
for c in callers(0x180166660):
print(" caller", c)
except Exception as e:
print(" callers() failed:", e)
print()
print("###### E. reader census: instructions with memory displacement 0x49 / 0x5c")
print(" CONTROLS in the same scan: 0x38 and 0x70")
want = {0x49: [], 0x5c: [], 0x38: [], 0x70: []}
total = 0
it = listing.getInstructions(True)
while it.hasNext():
ins = it.next()
total += 1
try:
n = ins.getNumOperands()
except Exception:
continue
for i in range(n):
try:
objs = ins.getOpObjects(i)
except Exception:
continue
if len(objs) < 2:
continue
has_reg = any(hasattr(o, "getName") for o in objs)
if not has_reg:
continue
for o in objs:
v = None
try:
v = int(o.getValue())
except Exception:
continue
if v in want:
f = fm.getFunctionContaining(ins.getAddress())
want[v].append((int(ins.getAddress().getOffset()),
f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0,
str(ins)))
print(" scanned %d instructions" % total)
for d in (0x38, 0x70, 0x49, 0x5c):
print(" disp %#04x -> %d hits (CONTROL)" % (d, len(want[d]))
if d in (0x38, 0x70) else " disp %#04x -> %d hits" % (d, len(want[d])))
print()
print("###### F. functions touching [reg+0x49], grouped (this displacement is rare)")
from collections import defaultdict
g = defaultdict(list)
for a, fn, ent, txt in want[0x49]:
g[(ent, fn)].append((a, txt))
for (ent, fn), lst in sorted(g.items()):
print(" %s (%#x) n=%d" % (fn, ent, len(lst)))
for a, txt in lst:
print(" %#x %s" % (a, txt))
print()
print("###### G. functions touching [reg+0x5c] that ALSO contain an itemState value")
g2 = defaultdict(list)
for a, fn, ent, txt in want[0x5c]:
g2[(ent, fn)].append((a, txt))
print(" total distinct functions touching +0x5c: %d" % len(g2))
for (ent, fn), lst in sorted(g2.items()):
if not ent:
continue
f = func(ent)
if f is None:
continue
imms = set()
it2 = listing.getInstructions(f.getBody(), True)
while it2.hasNext():
ins = it2.next()
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
imms.add(int(o.getValue()))
except Exception:
pass
hit = imms & {0x64, 0x65, 0x66, 0x67, 0x68, 0xff, 5, 6}
if 0x64 in imms or 0x67 in imms or 0x68 in imms:
print(" *** %s (%#x) n=%d state-ish immediates=%s"
% (fn, ent, len(lst), sorted(hex(x) for x in hit)))
for a, txt in lst:
print(" %#x %s" % (a, txt))
except Exception:
traceback.print_exc()

Some files were not shown because too many files have changed in this diff Show More